Compare commits
91 Commits
feat/bette
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4413c311e9 | |||
| 35a1357203 | |||
| f795be4274 | |||
| 4fd6254e93 | |||
| aa95dc534d | |||
| 3643d50f1a | |||
| 8b9f9f0a74 | |||
| 271a574f46 | |||
| 3787541733 | |||
| dd632d05e3 | |||
| 3c02a8ab23 | |||
| 907d676d39 | |||
| 4a96a9675c | |||
| 29007a7a43 | |||
| 275adaddc3 | |||
| 06e3dae085 | |||
| d04144933d | |||
| 411aa946ee | |||
| 2a8f455356 | |||
| f7cdbcb4d4 | |||
| 00c78c88d2 | |||
| 471a0f90b5 | |||
| 5b9b55c24d | |||
| 75aba0413f | |||
| 448831f72b | |||
| 5818b372a3 | |||
| f7456dde5e | |||
| 0ab841674c | |||
| a51e980608 | |||
| 94e5bbdb8a | |||
| d06426775b | |||
| 3d31925216 | |||
| 04e54c6f6e | |||
| ca97cdbfba | |||
| c20e6c2637 | |||
| 25308c2392 | |||
| 4437771a12 | |||
| d52b5d7d0e | |||
| 307c2a4cd4 | |||
| 978f6021de | |||
| 48a6f28e59 | |||
| 77d66a0bba | |||
| 849586d577 | |||
| f9cadcca3d | |||
| e1a5b52d07 | |||
| 1039ea153d | |||
| c5174b402c | |||
| 11d8666a4d | |||
| 78e8185439 | |||
| 806381a992 | |||
| 6db5512b84 | |||
| 59026f3be5 | |||
| 48910c8da2 | |||
| f812570a23 | |||
| ba24c5135c | |||
| b2fd9561df | |||
| 13855ba7fc | |||
| 6fc77f847c | |||
| 3b30e8723c | |||
| cb51b7e0ab | |||
|
|
f55bd6f99b | ||
|
|
754b495fb8 | ||
|
|
2d536dd0fa | ||
|
|
711d21365d | ||
|
|
8bdc808074 | ||
|
|
b2387cc6fa | ||
|
|
28bdd0dfa6 | ||
|
|
1477da4987 | ||
|
|
ef5540f278 | ||
|
|
745c299563 | ||
|
|
3b35fa53d9 | ||
|
|
01413eea3d | ||
|
|
6fa94d3eb8 | ||
|
|
4202db1c99 | ||
|
|
026890cd27 | ||
|
|
6c0aad423f | ||
|
|
774ebd1eaa | ||
|
|
d5d362e52b | ||
|
|
d7ce19e15a | ||
|
|
2ccaf6be6d | ||
|
|
90f0bcde44 | ||
|
|
801c95a829 | ||
|
|
872dbb4151 | ||
|
|
cb2bee19b7 | ||
|
|
961d3b1d3b | ||
|
|
f0f81945fb | ||
|
|
bdc65c5713 | ||
|
|
07ed2cfed4 | ||
|
|
5b8f0cba04 | ||
|
|
8b7af65265 | ||
|
|
30df16f5b5 |
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 }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -67,7 +67,7 @@ bower_components/
|
||||
.flooignore
|
||||
|
||||
#config file
|
||||
librechat.yaml
|
||||
#librechat.yaml
|
||||
librechat.yml
|
||||
|
||||
# Environment
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.8.1-rc1
|
||||
# v0.8.1-rc2
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile.multi
|
||||
# v0.8.1-rc1
|
||||
# v0.8.1-rc2
|
||||
|
||||
# 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');
|
||||
|
||||
@@ -1213,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,
|
||||
@@ -1231,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,
|
||||
);
|
||||
@@ -1246,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,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(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { ObjectId } = require('mongodb');
|
||||
const { escapeRegExp } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
Constants,
|
||||
@@ -14,7 +15,6 @@ const {
|
||||
} = require('./Project');
|
||||
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||
const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
|
||||
const { escapeRegExp } = require('~/server/utils');
|
||||
|
||||
/**
|
||||
* Create a pipeline for the aggregation to get prompt groups
|
||||
|
||||
@@ -141,6 +141,7 @@ const tokenValues = Object.assign(
|
||||
'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 },
|
||||
@@ -173,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 },
|
||||
@@ -243,6 +247,10 @@ const cacheTokenValues = {
|
||||
'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', () => {
|
||||
@@ -1205,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,
|
||||
@@ -1240,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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.8.1-rc1",
|
||||
"version": "v0.8.1-rc2",
|
||||
"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.32",
|
||||
"@librechat/agents": "^3.0.36",
|
||||
"@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",
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ const {
|
||||
isEnabled,
|
||||
ErrorController,
|
||||
performStartupChecks,
|
||||
handleJsonParseError,
|
||||
initializeFileStorage,
|
||||
} = require('@librechat/api');
|
||||
const { connectDb, indexSync } = require('~/db');
|
||||
@@ -245,6 +246,7 @@ if (cluster.isMaster) {
|
||||
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());
|
||||
@@ -290,7 +292,6 @@ if (cluster.isMaster) {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
@@ -103,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);
|
||||
}
|
||||
|
||||
@@ -436,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 ';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -665,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'],
|
||||
@@ -677,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', () => {
|
||||
@@ -705,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 = {
|
||||
@@ -778,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);
|
||||
@@ -797,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -820,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', () => {
|
||||
@@ -841,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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** v0.8.1-rc1 */
|
||||
/** v0.8.1-rc2 */
|
||||
module.exports = {
|
||||
roots: ['<rootDir>/src'],
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.8.1-rc1",
|
||||
"version": "v0.8.1-rc2",
|
||||
"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",
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,8 +145,7 @@ export default function OpenAIImageGen({
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialProgress, quality]);
|
||||
}, [isSubmitting, initialProgress, quality]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialProgress >= 1 || cancelled) {
|
||||
|
||||
@@ -45,6 +45,9 @@ const extractMessageContent = (message: TMessage): string => {
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.map((part) => {
|
||||
if (part == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof part === 'string') {
|
||||
return part;
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ const Conversations: FC<ConversationsProps> = ({
|
||||
return (
|
||||
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
|
||||
{({ registerChild }) => (
|
||||
<div ref={registerChild} style={style} className="px-1">
|
||||
<div ref={registerChild} style={style}>
|
||||
{rendering}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -133,9 +133,7 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
|
||||
isActiveConvo
|
||||
? 'bg-surface-active-alt outline outline-2 outline-offset-[-2px]'
|
||||
: 'hover:bg-surface-active-alt',
|
||||
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={renaming ? -1 : 0}
|
||||
|
||||
@@ -55,6 +55,11 @@ export function DeleteConversationDialog({
|
||||
}
|
||||
setMenuOpen?.(false);
|
||||
retainView();
|
||||
showToast({
|
||||
message: localize('com_ui_convo_delete_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Input, Label, Button, TooltipAnchor, CircleHelpIcon } from '@librechat/client';
|
||||
import { Input, Label, Button } from '@librechat/client';
|
||||
import { useMCPAuthValuesQuery } from '~/data-provider/Tools/queries';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
@@ -27,21 +28,40 @@ interface AuthFieldProps {
|
||||
function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const sanitizer = useMemo(() => {
|
||||
const instance = DOMPurify();
|
||||
instance.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName && node.tagName === 'A') {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
return instance;
|
||||
}, []);
|
||||
|
||||
const sanitizedDescription = useMemo(() => {
|
||||
if (!config.description) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return sanitizer.sanitize(config.description, {
|
||||
ALLOWED_TAGS: ['a', 'strong', 'b', 'em', 'i', 'br', 'code'],
|
||||
ALLOWED_ATTR: ['href', 'class', 'target', 'rel'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOW_ARIA_ATTR: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Sanitization failed', error);
|
||||
return config.description;
|
||||
}
|
||||
}, [config.description, sanitizer]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<TooltipAnchor
|
||||
enableHTML={true}
|
||||
description={config.description || ''}
|
||||
render={
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={name} className="text-sm font-medium">
|
||||
{config.title}
|
||||
</Label>
|
||||
<CircleHelpIcon className="h-6 w-6 cursor-help text-text-secondary transition-colors hover:text-text-primary" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={name} className="text-sm font-medium">
|
||||
{config.title}
|
||||
</Label>
|
||||
{hasValue ? (
|
||||
<div className="flex min-w-fit items-center gap-2 whitespace-nowrap rounded-full border border-border-light px-2 py-0.5 text-xs font-medium text-text-secondary">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
@@ -66,12 +86,18 @@ function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps)
|
||||
placeholder={
|
||||
hasValue
|
||||
? localize('com_ui_mcp_update_var', { 0: config.title })
|
||||
: `${localize('com_ui_mcp_enter_var', { 0: config.title })} ${localize('com_ui_optional')}`
|
||||
: localize('com_ui_mcp_enter_var', { 0: config.title })
|
||||
}
|
||||
className="w-full rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary placeholder:text-text-secondary focus:outline-none sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{sanitizedDescription && (
|
||||
<p
|
||||
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:underline"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedDescription }}
|
||||
/>
|
||||
)}
|
||||
{errors[name] && <p className="text-xs text-red-500">{errors[name]?.message}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -93,6 +93,11 @@ export default function ArchivedChatsTable({
|
||||
onSuccess: async () => {
|
||||
setIsDeleteOpen(false);
|
||||
await refetch();
|
||||
showToast({
|
||||
message: localize('com_ui_convo_delete_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
showToast({
|
||||
|
||||
@@ -41,9 +41,11 @@ const toggleSwitchConfigs = [
|
||||
export const ThemeSelector = ({
|
||||
theme,
|
||||
onChange,
|
||||
portal = true,
|
||||
}: {
|
||||
theme: string;
|
||||
onChange: (value: string) => void;
|
||||
portal?: boolean;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
@@ -67,6 +69,7 @@ export const ThemeSelector = ({
|
||||
testId="theme-selector"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
portal={portal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -227,9 +227,13 @@ function ShareHeader({
|
||||
<OGDialogTitle>{settingsLabel}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2 text-sm">
|
||||
<ThemeSelector theme={theme} onChange={onThemeChange} />
|
||||
<div className="relative focus-within:z-[100]">
|
||||
<ThemeSelector theme={theme} onChange={onThemeChange} portal={false} />
|
||||
</div>
|
||||
<div className="bg-border-medium/60 h-px w-full" />
|
||||
<LangSelector langcode={langcode} onChange={onLangChange} portal={false} />
|
||||
<div className="relative focus-within:z-[100]">
|
||||
<LangSelector langcode={langcode} onChange={onLangChange} portal={false} />
|
||||
</div>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
|
||||
@@ -168,6 +168,7 @@ export default function useChatFunctions({
|
||||
|
||||
const endpointsConfig = queryClient.getQueryData<TEndpointsConfig>([QueryKeys.endpoints]);
|
||||
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
|
||||
const iconURL = conversation?.iconURL;
|
||||
|
||||
/** This becomes part of the `endpointOption` */
|
||||
const convo = parseCompactConvo({
|
||||
@@ -248,9 +249,9 @@ export default function useChatFunctions({
|
||||
conversationId,
|
||||
unfinished: false,
|
||||
isCreatedByUser: false,
|
||||
iconURL: convo?.iconURL,
|
||||
model: convo?.model,
|
||||
error: false,
|
||||
iconURL,
|
||||
};
|
||||
|
||||
if (isAssistantsEndpoint(endpoint)) {
|
||||
|
||||
@@ -73,7 +73,9 @@ export default function useExportConversation({
|
||||
}
|
||||
|
||||
return message.content
|
||||
.filter((content) => content != null)
|
||||
.map((content) => getMessageContent(message.sender || '', content))
|
||||
.filter((text) => text.length > 0)
|
||||
.map((text) => {
|
||||
return formatText(text[0], text[1]);
|
||||
})
|
||||
@@ -103,7 +105,7 @@ export default function useExportConversation({
|
||||
if (content.type === ContentTypes.TEXT) {
|
||||
// TEXT
|
||||
const textPart = content[ContentTypes.TEXT];
|
||||
const text = typeof textPart === 'string' ? textPart : textPart.value;
|
||||
const text = typeof textPart === 'string' ? textPart : (textPart?.value ?? '');
|
||||
return [sender, text];
|
||||
}
|
||||
|
||||
@@ -365,12 +367,10 @@ export default function useExportConversation({
|
||||
data['messages'] = messages;
|
||||
}
|
||||
|
||||
exportFromJSON({
|
||||
data: data,
|
||||
fileName: filename,
|
||||
extension: 'json',
|
||||
exportType: exportFromJSON.types.json,
|
||||
});
|
||||
/** Use JSON.stringify without indentation to minimize file size for deeply nested recursive exports */
|
||||
const jsonString = JSON.stringify(data);
|
||||
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8' });
|
||||
download(blob, `${filename}.json`, 'application/json');
|
||||
};
|
||||
|
||||
const exportConversation = () => {
|
||||
|
||||
@@ -33,9 +33,8 @@ export default function useContentHandler({ setMessages, getMessages }: TUseCont
|
||||
|
||||
const _messages = getMessages();
|
||||
const messages =
|
||||
_messages
|
||||
?.filter((m) => m.messageId !== messageId)
|
||||
.map((msg) => ({ ...msg, thread_id })) ?? [];
|
||||
_messages?.filter((m) => m.messageId !== messageId).map((msg) => ({ ...msg, thread_id })) ??
|
||||
[];
|
||||
const userMessage = messages[messages.length - 1] as TMessage | undefined;
|
||||
|
||||
const { initialResponse } = submission;
|
||||
@@ -66,14 +65,17 @@ export default function useContentHandler({ setMessages, getMessages }: TUseCont
|
||||
|
||||
response.content[index] = { type, [type]: part } as TMessageContentParts;
|
||||
|
||||
const lastContentPart = response.content[response.content.length - 1];
|
||||
const initialContentPart = initialResponse.content?.[0];
|
||||
if (
|
||||
type !== ContentTypes.TEXT &&
|
||||
initialResponse.content &&
|
||||
((response.content[response.content.length - 1].type === ContentTypes.TOOL_CALL &&
|
||||
response.content[response.content.length - 1][ContentTypes.TOOL_CALL].progress === 1) ||
|
||||
response.content[response.content.length - 1].type === ContentTypes.IMAGE_FILE)
|
||||
initialContentPart != null &&
|
||||
lastContentPart != null &&
|
||||
((lastContentPart.type === ContentTypes.TOOL_CALL &&
|
||||
lastContentPart[ContentTypes.TOOL_CALL]?.progress === 1) ||
|
||||
lastContentPart.type === ContentTypes.IMAGE_FILE)
|
||||
) {
|
||||
response.content.push(initialResponse.content[0]);
|
||||
response.content.push(initialContentPart);
|
||||
}
|
||||
|
||||
setMessages([...messages, response]);
|
||||
|
||||
@@ -87,12 +87,14 @@ const createErrorMessage = ({
|
||||
let isValidContentPart = false;
|
||||
if (latestContent.length > 0) {
|
||||
const latestContentPart = latestContent[latestContent.length - 1];
|
||||
const latestPartValue = latestContentPart?.[latestContentPart.type ?? ''];
|
||||
isValidContentPart =
|
||||
latestContentPart.type !== ContentTypes.TEXT ||
|
||||
(latestContentPart.type === ContentTypes.TEXT && typeof latestPartValue === 'string')
|
||||
? true
|
||||
: latestPartValue?.value !== '';
|
||||
if (latestContentPart != null) {
|
||||
const latestPartValue = latestContentPart[latestContentPart.type ?? ''];
|
||||
isValidContentPart =
|
||||
latestContentPart.type !== ContentTypes.TEXT ||
|
||||
(latestContentPart.type === ContentTypes.TEXT && typeof latestPartValue === 'string')
|
||||
? true
|
||||
: latestPartValue?.value !== '';
|
||||
}
|
||||
}
|
||||
if (
|
||||
latestMessage?.conversationId &&
|
||||
@@ -455,141 +457,145 @@ export default function useEventHandlers({
|
||||
isTemporary = false,
|
||||
} = submission;
|
||||
|
||||
if (responseMessage?.attachments && responseMessage.attachments.length > 0) {
|
||||
// Process each attachment through the attachmentHandler
|
||||
responseMessage.attachments.forEach((attachment) => {
|
||||
const attachmentData = {
|
||||
...attachment,
|
||||
messageId: responseMessage.messageId,
|
||||
};
|
||||
try {
|
||||
if (responseMessage?.attachments && responseMessage.attachments.length > 0) {
|
||||
// Process each attachment through the attachmentHandler
|
||||
responseMessage.attachments.forEach((attachment) => {
|
||||
const attachmentData = {
|
||||
...attachment,
|
||||
messageId: responseMessage.messageId,
|
||||
};
|
||||
|
||||
attachmentHandler({
|
||||
data: attachmentData,
|
||||
submission: submission as EventSubmission,
|
||||
attachmentHandler({
|
||||
data: attachmentData,
|
||||
submission: submission as EventSubmission,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setShowStopButton(false);
|
||||
setCompleted((prev) => new Set(prev.add(submission.initialResponse.messageId)));
|
||||
setCompleted((prev) => new Set(prev.add(submission.initialResponse.messageId)));
|
||||
|
||||
const currentMessages = getMessages();
|
||||
/* Early return if messages are empty; i.e., the user navigated away */
|
||||
if (!currentMessages || currentMessages.length === 0) {
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const currentMessages = getMessages();
|
||||
/* Early return if messages are empty; i.e., the user navigated away */
|
||||
if (!currentMessages || currentMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* a11y announcements */
|
||||
announcePolite({ message: 'end', isStatus: true });
|
||||
announcePolite({ message: getAllContentText(responseMessage) });
|
||||
/* a11y announcements */
|
||||
announcePolite({ message: 'end', isStatus: true });
|
||||
announcePolite({ message: getAllContentText(responseMessage) });
|
||||
|
||||
const isNewConvo = conversation.conversationId !== submissionConvo.conversationId;
|
||||
const isNewConvo = conversation.conversationId !== submissionConvo.conversationId;
|
||||
|
||||
const setFinalMessages = (id: string | null, _messages: TMessage[]) => {
|
||||
setMessages(_messages);
|
||||
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, id], _messages);
|
||||
};
|
||||
const setFinalMessages = (id: string | null, _messages: TMessage[]) => {
|
||||
setMessages(_messages);
|
||||
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, id], _messages);
|
||||
};
|
||||
|
||||
const hasNoResponse =
|
||||
responseMessage?.content?.[0]?.['text']?.value ===
|
||||
submission.initialResponse?.content?.[0]?.['text']?.value ||
|
||||
!!responseMessage?.content?.[0]?.['tool_call']?.auth;
|
||||
const hasNoResponse =
|
||||
responseMessage?.content?.[0]?.['text']?.value ===
|
||||
submission.initialResponse?.content?.[0]?.['text']?.value ||
|
||||
!!responseMessage?.content?.[0]?.['tool_call']?.auth;
|
||||
|
||||
/** Handle edge case where stream is cancelled before any response, which creates a blank page */
|
||||
if (!conversation.conversationId && hasNoResponse) {
|
||||
const currentConvoId =
|
||||
(submissionConvo.conversationId ?? conversation.conversationId) || Constants.NEW_CONVO;
|
||||
if (isNewConvo && submissionConvo.conversationId) {
|
||||
removeConvoFromAllQueries(queryClient, submissionConvo.conversationId);
|
||||
}
|
||||
|
||||
const isNewChat =
|
||||
location.pathname === `/c/${Constants.NEW_CONVO}` &&
|
||||
currentConvoId === Constants.NEW_CONVO;
|
||||
|
||||
setFinalMessages(currentConvoId, isNewChat ? [] : [...messages]);
|
||||
setDraft({ id: currentConvoId, value: requestMessage?.text });
|
||||
if (isNewChat) {
|
||||
navigate(`/c/${Constants.NEW_CONVO}`, { replace: true, state: { focusChat: true } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* Update messages; if assistants endpoint, client doesn't receive responseMessage */
|
||||
let finalMessages: TMessage[] = [];
|
||||
if (runMessages) {
|
||||
finalMessages = [...runMessages];
|
||||
} else if (isRegenerate && responseMessage) {
|
||||
finalMessages = [...messages, responseMessage];
|
||||
} else if (requestMessage != null && responseMessage != null) {
|
||||
finalMessages = [...messages, requestMessage, responseMessage];
|
||||
}
|
||||
if (finalMessages.length > 0) {
|
||||
setFinalMessages(conversation.conversationId, finalMessages);
|
||||
} else if (
|
||||
isAssistantsEndpoint(submissionConvo.endpoint) &&
|
||||
(!submissionConvo.conversationId ||
|
||||
submissionConvo.conversationId === Constants.NEW_CONVO)
|
||||
) {
|
||||
queryClient.setQueryData<TMessage[]>(
|
||||
[QueryKeys.messages, conversation.conversationId],
|
||||
[...currentMessages],
|
||||
);
|
||||
}
|
||||
|
||||
/** Handle edge case where stream is cancelled before any response, which creates a blank page */
|
||||
if (!conversation.conversationId && hasNoResponse) {
|
||||
const currentConvoId =
|
||||
(submissionConvo.conversationId ?? conversation.conversationId) || Constants.NEW_CONVO;
|
||||
if (isNewConvo && submissionConvo.conversationId) {
|
||||
removeConvoFromAllQueries(queryClient, submissionConvo.conversationId);
|
||||
}
|
||||
|
||||
const isNewChat =
|
||||
location.pathname === `/c/${Constants.NEW_CONVO}` &&
|
||||
currentConvoId === Constants.NEW_CONVO;
|
||||
|
||||
setFinalMessages(currentConvoId, isNewChat ? [] : [...messages]);
|
||||
setDraft({ id: currentConvoId, value: requestMessage?.text });
|
||||
setIsSubmitting(false);
|
||||
if (isNewChat) {
|
||||
navigate(`/c/${Constants.NEW_CONVO}`, { replace: true, state: { focusChat: true } });
|
||||
/* Refresh title */
|
||||
if (
|
||||
genTitle &&
|
||||
isNewConvo &&
|
||||
!isTemporary &&
|
||||
requestMessage &&
|
||||
requestMessage.parentMessageId === Constants.NO_PARENT
|
||||
) {
|
||||
setTimeout(() => {
|
||||
genTitle.mutate({ conversationId: conversation.conversationId as string });
|
||||
}, 2500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* Update messages; if assistants endpoint, client doesn't receive responseMessage */
|
||||
let finalMessages: TMessage[] = [];
|
||||
if (runMessages) {
|
||||
finalMessages = [...runMessages];
|
||||
} else if (isRegenerate && responseMessage) {
|
||||
finalMessages = [...messages, responseMessage];
|
||||
} else if (requestMessage != null && responseMessage != null) {
|
||||
finalMessages = [...messages, requestMessage, responseMessage];
|
||||
}
|
||||
if (finalMessages.length > 0) {
|
||||
setFinalMessages(conversation.conversationId, finalMessages);
|
||||
} else if (
|
||||
isAssistantsEndpoint(submissionConvo.endpoint) &&
|
||||
(!submissionConvo.conversationId || submissionConvo.conversationId === Constants.NEW_CONVO)
|
||||
) {
|
||||
queryClient.setQueryData<TMessage[]>(
|
||||
[QueryKeys.messages, conversation.conversationId],
|
||||
[...currentMessages],
|
||||
);
|
||||
}
|
||||
|
||||
if (isNewConvo && submissionConvo.conversationId) {
|
||||
removeConvoFromAllQueries(queryClient, submissionConvo.conversationId);
|
||||
}
|
||||
|
||||
/* Refresh title */
|
||||
if (
|
||||
genTitle &&
|
||||
isNewConvo &&
|
||||
!isTemporary &&
|
||||
requestMessage &&
|
||||
requestMessage.parentMessageId === Constants.NO_PARENT
|
||||
) {
|
||||
setTimeout(() => {
|
||||
genTitle.mutate({ conversationId: conversation.conversationId as string });
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
if (setConversation && isAddedRequest !== true) {
|
||||
setConversation((prevState) => {
|
||||
const update = {
|
||||
...prevState,
|
||||
...(conversation as TConversation),
|
||||
};
|
||||
if (prevState?.model != null && prevState.model !== submissionConvo.model) {
|
||||
update.model = prevState.model;
|
||||
}
|
||||
const cachedConvo = queryClient.getQueryData<TConversation>([
|
||||
QueryKeys.conversation,
|
||||
conversation.conversationId,
|
||||
]);
|
||||
if (!cachedConvo) {
|
||||
queryClient.setQueryData([QueryKeys.conversation, conversation.conversationId], update);
|
||||
}
|
||||
return update;
|
||||
});
|
||||
|
||||
if (conversation.conversationId && submission.ephemeralAgent) {
|
||||
applyAgentTemplate({
|
||||
targetId: conversation.conversationId,
|
||||
sourceId: submissionConvo.conversationId,
|
||||
ephemeralAgent: submission.ephemeralAgent,
|
||||
specName: submission.conversation?.spec,
|
||||
startupConfig: queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]),
|
||||
if (setConversation && isAddedRequest !== true) {
|
||||
setConversation((prevState) => {
|
||||
const update = {
|
||||
...prevState,
|
||||
...(conversation as TConversation),
|
||||
};
|
||||
if (prevState?.model != null && prevState.model !== submissionConvo.model) {
|
||||
update.model = prevState.model;
|
||||
}
|
||||
const cachedConvo = queryClient.getQueryData<TConversation>([
|
||||
QueryKeys.conversation,
|
||||
conversation.conversationId,
|
||||
]);
|
||||
if (!cachedConvo) {
|
||||
queryClient.setQueryData(
|
||||
[QueryKeys.conversation, conversation.conversationId],
|
||||
update,
|
||||
);
|
||||
}
|
||||
return update;
|
||||
});
|
||||
}
|
||||
|
||||
if (location.pathname === `/c/${Constants.NEW_CONVO}`) {
|
||||
navigate(`/c/${conversation.conversationId}`, { replace: true });
|
||||
if (conversation.conversationId && submission.ephemeralAgent) {
|
||||
applyAgentTemplate({
|
||||
targetId: conversation.conversationId,
|
||||
sourceId: submissionConvo.conversationId,
|
||||
ephemeralAgent: submission.ephemeralAgent,
|
||||
specName: submission.conversation?.spec,
|
||||
startupConfig: queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]),
|
||||
});
|
||||
}
|
||||
|
||||
if (location.pathname === `/c/${Constants.NEW_CONVO}`) {
|
||||
navigate(`/c/${conversation.conversationId}`, { replace: true });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setShowStopButton(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
[
|
||||
navigate,
|
||||
@@ -722,26 +728,37 @@ export default function useEventHandlers({
|
||||
messages[messages.length - 2] != null
|
||||
) {
|
||||
let requestMessage = messages[messages.length - 2];
|
||||
const responseMessage = messages[messages.length - 1];
|
||||
if (requestMessage.messageId !== responseMessage.parentMessageId) {
|
||||
const _responseMessage = messages[messages.length - 1];
|
||||
if (requestMessage.messageId !== _responseMessage.parentMessageId) {
|
||||
// the request message is the parent of response, which we search for backwards
|
||||
for (let i = messages.length - 3; i >= 0; i--) {
|
||||
if (messages[i].messageId === responseMessage.parentMessageId) {
|
||||
if (messages[i].messageId === _responseMessage.parentMessageId) {
|
||||
requestMessage = messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finalHandler(
|
||||
{
|
||||
conversation: {
|
||||
conversationId,
|
||||
/** Sanitize content array to remove undefined parts from interrupted streaming */
|
||||
const responseMessage = {
|
||||
..._responseMessage,
|
||||
content: _responseMessage.content?.filter((part) => part != null),
|
||||
};
|
||||
try {
|
||||
finalHandler(
|
||||
{
|
||||
conversation: {
|
||||
conversationId,
|
||||
},
|
||||
requestMessage,
|
||||
responseMessage,
|
||||
},
|
||||
requestMessage,
|
||||
responseMessage,
|
||||
},
|
||||
submission,
|
||||
);
|
||||
submission,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in finalHandler during abort:', error);
|
||||
setShowStopButton(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
return;
|
||||
} else if (!isAssistantsEndpoint(endpoint)) {
|
||||
const convoId = conversationId || `_${v4()}`;
|
||||
@@ -809,13 +826,14 @@ export default function useEventHandlers({
|
||||
}
|
||||
},
|
||||
[
|
||||
finalHandler,
|
||||
newConversation,
|
||||
setIsSubmitting,
|
||||
token,
|
||||
cancelHandler,
|
||||
getMessages,
|
||||
setMessages,
|
||||
finalHandler,
|
||||
cancelHandler,
|
||||
newConversation,
|
||||
setIsSubmitting,
|
||||
setShowStopButton,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -124,7 +124,13 @@ export default function useSSE(
|
||||
if (data.final != null) {
|
||||
clearDraft(submission.conversation?.conversationId);
|
||||
const { plugins } = data;
|
||||
finalHandler(data, { ...submission, plugins } as EventSubmission);
|
||||
try {
|
||||
finalHandler(data, { ...submission, plugins } as EventSubmission);
|
||||
} catch (error) {
|
||||
console.error('Error in finalHandler:', error);
|
||||
setIsSubmitting(false);
|
||||
setShowStopButton(false);
|
||||
}
|
||||
(startupConfig?.balance?.enabled ?? false) && balanceQuery.refetch();
|
||||
console.log('final', data);
|
||||
return;
|
||||
@@ -187,14 +193,20 @@ export default function useSSE(
|
||||
setCompleted((prev) => new Set(prev.add(streamKey)));
|
||||
const latestMessages = getMessages();
|
||||
const conversationId = latestMessages?.[latestMessages.length - 1]?.conversationId;
|
||||
return await abortConversation(
|
||||
conversationId ??
|
||||
userMessage.conversationId ??
|
||||
submission.conversation?.conversationId ??
|
||||
'',
|
||||
submission as EventSubmission,
|
||||
latestMessages,
|
||||
);
|
||||
try {
|
||||
await abortConversation(
|
||||
conversationId ??
|
||||
userMessage.conversationId ??
|
||||
submission.conversation?.conversationId ??
|
||||
'',
|
||||
submission as EventSubmission,
|
||||
latestMessages,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error during abort:', error);
|
||||
setIsSubmitting(false);
|
||||
setShowStopButton(false);
|
||||
}
|
||||
});
|
||||
|
||||
sse.addEventListener('error', async (e: MessageEvent) => {
|
||||
|
||||
@@ -313,6 +313,10 @@ export default function useStepHandler({
|
||||
? messageDelta.delta.content[0]
|
||||
: messageDelta.delta.content;
|
||||
|
||||
if (contentPart == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = calculateContentIndex(
|
||||
runStep.index,
|
||||
initialContent,
|
||||
@@ -345,6 +349,10 @@ export default function useStepHandler({
|
||||
? reasoningDelta.delta.content[0]
|
||||
: reasoningDelta.delta.content;
|
||||
|
||||
if (contentPart == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = calculateContentIndex(
|
||||
runStep.index,
|
||||
initialContent,
|
||||
|
||||
@@ -799,6 +799,7 @@
|
||||
"com_ui_continue_oauth": "Continue with OAuth",
|
||||
"com_ui_controls": "Controls",
|
||||
"com_ui_convo_delete_error": "Failed to delete conversation",
|
||||
"com_ui_convo_delete_success": "Conversation successfully deleted",
|
||||
"com_ui_copied": "Copied!",
|
||||
"com_ui_copied_to_clipboard": "Copied to clipboard",
|
||||
"com_ui_copy_code": "Copy code",
|
||||
|
||||
@@ -1487,6 +1487,26 @@ button {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Show scrollbar only on hover */
|
||||
.scrollbar-hover {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.scrollbar-hover:hover {
|
||||
scrollbar-color: var(--border-medium) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-hover::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
transition: background-color 0.3s ease 0.5s;
|
||||
}
|
||||
|
||||
.scrollbar-hover:hover::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-medium);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
import {
|
||||
megabyte,
|
||||
QueryKeys,
|
||||
inferMimeType,
|
||||
excelMimeTypes,
|
||||
EToolResources,
|
||||
codeTypeMapping,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TFile, EndpointFileConfig, FileConfig } from 'librechat-data-provider';
|
||||
@@ -257,14 +257,7 @@ export const validateFiles = ({
|
||||
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
let originalFile = fileList[i];
|
||||
let fileType = originalFile.type;
|
||||
const extension = originalFile.name.split('.').pop() ?? '';
|
||||
const knownCodeType = codeTypeMapping[extension];
|
||||
|
||||
// Infer MIME type for Known Code files when the type is empty or a mismatch
|
||||
if (knownCodeType && (!fileType || fileType !== knownCodeType)) {
|
||||
fileType = knownCodeType;
|
||||
}
|
||||
const fileType = inferMimeType(originalFile.name, originalFile.type);
|
||||
|
||||
// Check if the file type is still empty after the extension check
|
||||
if (!fileType) {
|
||||
|
||||
@@ -44,7 +44,7 @@ export const getAllContentText = (message?: TMessage | null): string => {
|
||||
|
||||
if (message.content && message.content.length > 0) {
|
||||
return message.content
|
||||
.filter((part) => part.type === ContentTypes.TEXT)
|
||||
.filter((part) => part != null && part.type === ContentTypes.TEXT)
|
||||
.map((part) => {
|
||||
if (!('text' in part)) return '';
|
||||
const text = part.text;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const path = require('path');
|
||||
const mongoose = require('mongoose');
|
||||
const { isEnabled, getBalanceConfig } = require('@librechat/api');
|
||||
const { getBalanceConfig } = require('@librechat/api');
|
||||
const { User } = require('@librechat/data-schemas').createModels(mongoose);
|
||||
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
|
||||
const { createTransaction } = require('~/models/Transaction');
|
||||
@@ -33,15 +33,12 @@ const connect = require('./connect');
|
||||
// console.purple(`[DEBUG] Args Length: ${process.argv.length}`);
|
||||
}
|
||||
|
||||
if (!process.env.CHECK_BALANCE) {
|
||||
const appConfig = await getAppConfig();
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
|
||||
if (!balanceConfig?.enabled) {
|
||||
console.red(
|
||||
'Error: CHECK_BALANCE environment variable is not set! Configure it to use it: `CHECK_BALANCE=true`',
|
||||
);
|
||||
silentExit(1);
|
||||
}
|
||||
if (isEnabled(process.env.CHECK_BALANCE) === false) {
|
||||
console.red(
|
||||
'Error: CHECK_BALANCE environment variable is set to `false`! Please configure: `CHECK_BALANCE=true`',
|
||||
'Error: Balance is not enabled. Use librechat.yaml to enable it',
|
||||
);
|
||||
silentExit(1);
|
||||
}
|
||||
@@ -80,8 +77,6 @@ const connect = require('./connect');
|
||||
*/
|
||||
let result;
|
||||
try {
|
||||
const appConfig = await getAppConfig();
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
result = await createTransaction({
|
||||
user: user._id,
|
||||
tokenType: 'credits',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const path = require('path');
|
||||
const mongoose = require('mongoose');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { getBalanceConfig } = require('@librechat/api');
|
||||
const { User, Balance } = require('@librechat/data-schemas').createModels(mongoose);
|
||||
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
|
||||
const { askQuestion, silentExit } = require('./helpers');
|
||||
@@ -31,15 +31,10 @@ const connect = require('./connect');
|
||||
// console.purple(`[DEBUG] Args Length: ${process.argv.length}`);
|
||||
}
|
||||
|
||||
if (!process.env.CHECK_BALANCE) {
|
||||
const balanceConfig = getBalanceConfig();
|
||||
if (!balanceConfig?.enabled) {
|
||||
console.red(
|
||||
'Error: CHECK_BALANCE environment variable is not set! Configure it to use it: `CHECK_BALANCE=true`',
|
||||
);
|
||||
silentExit(1);
|
||||
}
|
||||
if (isEnabled(process.env.CHECK_BALANCE) === false) {
|
||||
console.red(
|
||||
'Error: CHECK_BALANCE environment variable is set to `false`! Please configure: `CHECK_BALANCE=true`',
|
||||
'Error: Balance is not enabled. Use librechat.yaml to enable it',
|
||||
);
|
||||
silentExit(1);
|
||||
}
|
||||
|
||||
259
deploy-compose.swarm.yml
Normal file
259
deploy-compose.swarm.yml
Normal file
@@ -0,0 +1,259 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
api:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile.multi
|
||||
# target: api-build
|
||||
image: ghcr.io/danny-avila/librechat-dev-api:latest
|
||||
# ports:
|
||||
# - 3080:3080
|
||||
# Note: depends_on is ignored in Docker Swarm mode
|
||||
# Services start in parallel, so API must handle connection retries
|
||||
# depends_on:
|
||||
# - mongodb
|
||||
# - rag_api
|
||||
networks:
|
||||
- net
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
env_file:
|
||||
- stack.env
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- NODE_ENV=production
|
||||
- MONGO_URI=mongodb://mongodb:27017/LibreChat
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- RAG_PORT=${RAG_PORT:-8000}
|
||||
- RAG_API_URL=http://rag_api:${RAG_PORT:-8000}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /home/trav/dkr/LibreChat/librechat.yaml
|
||||
target: /app/librechat.yaml
|
||||
- /home/trav/dkr/LibreChat/images:/app/client/public/images
|
||||
- /home/trav/dkr/LibreChat/uploads:/app/uploads
|
||||
- /home/trav/dkr/LibreChat/logs:/app/api/logs
|
||||
- /home/trav/claude-scripts:/mnt/claude-scripts
|
||||
- /home/trav/dkr:/mnt/dkr
|
||||
- /home/trav/biz-bud:/mnt/biz-bud
|
||||
- /home/trav/portainer:/mnt/portainer
|
||||
- /home/trav/repos:/mnt/repos
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:3080/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: any
|
||||
delay: 20s
|
||||
max_attempts: 10
|
||||
window: 2m
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 20s
|
||||
failure_action: rollback
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == little
|
||||
|
||||
client:
|
||||
image: nginx:1.27.0-alpine
|
||||
# ports:
|
||||
# - 80:80
|
||||
# - 443:443
|
||||
# Note: depends_on is ignored in Docker Swarm mode
|
||||
# depends_on:
|
||||
# - api
|
||||
networks:
|
||||
- net
|
||||
- badge-net
|
||||
volumes:
|
||||
- /home/trav/dkr/LibreChat/client/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: any
|
||||
delay: 15s
|
||||
max_attempts: 10
|
||||
window: 2m
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 15s
|
||||
failure_action: rollback
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == little
|
||||
|
||||
mongodb:
|
||||
# ports: # Uncomment this to access mongodb from outside docker, not safe in deployment
|
||||
# - 27018:27017
|
||||
image: mongo
|
||||
networks:
|
||||
- net
|
||||
volumes:
|
||||
- librechat-mongodb:/data/db
|
||||
command: mongod --noauth
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mongosh --eval 'db.adminCommand(\"ping\")' --quiet || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: any
|
||||
delay: 20s
|
||||
max_attempts: 15
|
||||
window: 3m
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 20s
|
||||
failure_action: rollback
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == little
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.12.3
|
||||
networks:
|
||||
- net
|
||||
# ports: # Uncomment this to access meilisearch from outside docker
|
||||
# - 7700:7700 # if exposing these ports, make sure your master key is not the default value
|
||||
env_file:
|
||||
- stack.env
|
||||
environment:
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- MEILI_NO_ANALYTICS=true
|
||||
volumes:
|
||||
- librechat-meili_data:/meili_data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:7700/health || wget --spider -q http://localhost:7700/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: any
|
||||
delay: 20s
|
||||
max_attempts: 15
|
||||
window: 3m
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 20s
|
||||
failure_action: rollback
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == little
|
||||
|
||||
vectordb:
|
||||
image: pgvector/pgvector:0.8.0-pg15-trixie
|
||||
environment:
|
||||
POSTGRES_DB: mydatabase
|
||||
POSTGRES_USER: myuser
|
||||
POSTGRES_PASSWORD: mypassword
|
||||
networks:
|
||||
- net
|
||||
volumes:
|
||||
- librechat-pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U myuser -d mydatabase"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: any
|
||||
delay: 20s
|
||||
max_attempts: 15
|
||||
window: 3m
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 20s
|
||||
failure_action: rollback
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == little
|
||||
|
||||
rag_api:
|
||||
image: ghcr.io/danny-avila/librechat-rag-api-dev-lite:latest
|
||||
environment:
|
||||
- DB_HOST=vectordb
|
||||
- RAG_PORT=${RAG_PORT:-8000}
|
||||
networks:
|
||||
- net
|
||||
# Note: depends_on is ignored in Docker Swarm mode
|
||||
# depends_on:
|
||||
# - vectordb
|
||||
env_file:
|
||||
- stack.env
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: any
|
||||
delay: 20s
|
||||
max_attempts: 15
|
||||
window: 3m
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 20s
|
||||
failure_action: rollback
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == little
|
||||
|
||||
metrics:
|
||||
image: ghcr.io/virtuos/librechat_exporter:main
|
||||
# Note: depends_on is ignored in Docker Swarm mode
|
||||
# depends_on:
|
||||
# - mongodb
|
||||
# ports:
|
||||
# - "8000:8000"
|
||||
networks:
|
||||
- net
|
||||
- observability_observability
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: any
|
||||
delay: 15s
|
||||
max_attempts: 10
|
||||
window: 2m
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 15s
|
||||
failure_action: rollback
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == little
|
||||
|
||||
volumes:
|
||||
librechat-pgdata:
|
||||
name: librechat-pgdata
|
||||
librechat-mongodb:
|
||||
name: librechat-mongodb
|
||||
librechat-meili_data:
|
||||
name: librechat-meili_data
|
||||
|
||||
networks:
|
||||
net:
|
||||
driver: overlay
|
||||
attachable: true
|
||||
badge-net:
|
||||
external: true
|
||||
observability_observability:
|
||||
external: true
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
services:
|
||||
api:
|
||||
container_name: LibreChat
|
||||
container_name: librechat
|
||||
ports:
|
||||
- "${PORT}:${PORT}"
|
||||
depends_on:
|
||||
@@ -24,17 +24,36 @@ services:
|
||||
- type: bind
|
||||
source: ./.env
|
||||
target: /app/.env
|
||||
- ./librechat.yaml:/app/librechat.yaml
|
||||
- ./images:/app/client/public/images
|
||||
- ./uploads:/app/uploads
|
||||
- ./logs:/app/logs
|
||||
networks:
|
||||
- chat-net
|
||||
client:
|
||||
image: nginx:1.27.0-alpine
|
||||
container_name: librechat-nginx
|
||||
expose:
|
||||
- 80
|
||||
- 443
|
||||
depends_on:
|
||||
- api
|
||||
networks:
|
||||
- chat-net
|
||||
- edge-little
|
||||
restart: always
|
||||
volumes:
|
||||
- ./client/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
mongodb:
|
||||
container_name: chat-mongodb
|
||||
container_name: librechat-mongodb
|
||||
image: mongo
|
||||
restart: always
|
||||
user: "${UID}:${GID}"
|
||||
volumes:
|
||||
- ./data-node:/data/db
|
||||
command: mongod --noauth
|
||||
networks:
|
||||
- chat-net
|
||||
meilisearch:
|
||||
container_name: chat-meilisearch
|
||||
image: getmeili/meilisearch:v1.12.3
|
||||
@@ -46,8 +65,10 @@ services:
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
volumes:
|
||||
- ./meili_data_v1.12:/meili_data
|
||||
networks:
|
||||
- chat-net
|
||||
vectordb:
|
||||
container_name: vectordb
|
||||
container_name: librechat-vectordb
|
||||
image: pgvector/pgvector:0.8.0-pg15-trixie
|
||||
environment:
|
||||
POSTGRES_DB: mydatabase
|
||||
@@ -55,9 +76,11 @@ services:
|
||||
POSTGRES_PASSWORD: mypassword
|
||||
restart: always
|
||||
volumes:
|
||||
- pgdata2:/var/lib/postgresql/data
|
||||
- librechat-pgdata:/var/lib/postgresql/data
|
||||
networks:
|
||||
- chat-net
|
||||
rag_api:
|
||||
container_name: rag_api
|
||||
container_name: librechat-rag_api
|
||||
image: ghcr.io/danny-avila/librechat-rag-api-dev-lite:latest
|
||||
environment:
|
||||
- DB_HOST=vectordb
|
||||
@@ -65,8 +88,17 @@ services:
|
||||
restart: always
|
||||
depends_on:
|
||||
- vectordb
|
||||
networks:
|
||||
- chat-net
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
volumes:
|
||||
pgdata2:
|
||||
librechat-pgdata:
|
||||
external: true
|
||||
networks:
|
||||
chat-net:
|
||||
driver: bridge
|
||||
name: chat-net
|
||||
edge-little:
|
||||
external: true
|
||||
@@ -1,3 +1,3 @@
|
||||
// v0.8.1-rc1
|
||||
// v0.8.1-rc2
|
||||
// See .env.test.example for an example of the '.env.test' file.
|
||||
require('dotenv').config({ path: './e2e/.env.test' });
|
||||
|
||||
@@ -269,6 +269,16 @@ export default [
|
||||
project: './packages/data-provider/tsconfig.json',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./api/demo/**/*.ts'],
|
||||
|
||||
@@ -15,7 +15,7 @@ type: application
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 1.9.2
|
||||
version: 1.9.3
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
@@ -23,7 +23,7 @@ version: 1.9.2
|
||||
# It is recommended to use it with quotes.
|
||||
|
||||
# renovate: image=ghcr.io/danny-avila/librechat
|
||||
appVersion: "v0.8.1-rc1"
|
||||
appVersion: "v0.8.1-rc2"
|
||||
|
||||
home: https://www.librechat.ai
|
||||
|
||||
|
||||
584
librechat.yaml
Normal file
584
librechat.yaml
Normal file
@@ -0,0 +1,584 @@
|
||||
# For more information, see the Configuration Guide:
|
||||
# https://www.librechat.ai/docs/configuration/librechat_yaml
|
||||
|
||||
# Configuration version (required)
|
||||
version: 1.3.1
|
||||
|
||||
# Cache settings: Set to true to enable caching
|
||||
cache: true
|
||||
|
||||
# File storage configuration
|
||||
# Single strategy for all file types (legacy format, still supported)
|
||||
fileStrategy: "s3"
|
||||
|
||||
# Granular file storage strategies (new format - recommended)
|
||||
# Allows different storage strategies for different file types
|
||||
# fileStrategy:
|
||||
# avatar: "s3" # Storage for user/agent avatar images
|
||||
# image: "firebase" # Storage for uploaded images in chats
|
||||
# document: "local" # Storage for document uploads (PDFs, text files, etc.)
|
||||
|
||||
# Available strategies: "local", "s3", "firebase"
|
||||
# If not specified, defaults to "local" for all file types
|
||||
# You can mix and match strategies based on your needs:
|
||||
# - Use S3 for avatars for fast global access
|
||||
# - Use Firebase for images with automatic optimization
|
||||
# - Use local storage for documents for privacy/compliance
|
||||
|
||||
ocr:
|
||||
apiKey: "YO2bXkUHLxlJdsXactjlLK4PRZMrBaCo"
|
||||
strategy: "mistral_ocr"
|
||||
mistralModel: "mistral-ocr-latest"
|
||||
|
||||
# Custom interface configuration
|
||||
interface:
|
||||
customWelcome: 'Welcome to LibreChat! Enjoy your experience.'
|
||||
# Enable/disable file search as a chatarea selection (default: true)
|
||||
# Note: This setting does not disable the Agents File Search Capability.
|
||||
# To disable the Agents Capability, see the Agents Endpoint configuration instead.
|
||||
fileSearch: true
|
||||
# Privacy policy settings
|
||||
privacyPolicy:
|
||||
externalUrl: 'https://librechat.ai/privacy-policy'
|
||||
openNewTab: true
|
||||
|
||||
# Terms of service
|
||||
termsOfService:
|
||||
externalUrl: 'https://librechat.ai/tos'
|
||||
openNewTab: true
|
||||
modalAcceptance: true
|
||||
modalTitle: 'Terms of Service for LibreChat'
|
||||
modalContent: |
|
||||
# Terms and Conditions for LibreChat
|
||||
|
||||
*Effective Date: February 18, 2024*
|
||||
|
||||
Welcome to LibreChat, the informational website for the open-source AI chat platform, available at https://librechat.ai. These Terms of Service ("Terms") govern your use of our website and the services we offer. By accessing or using the Website, you agree to be bound by these Terms and our Privacy Policy, accessible at https://librechat.ai//privacy.
|
||||
|
||||
## 1. Ownership
|
||||
|
||||
Upon purchasing a package from LibreChat, you are granted the right to download and use the code for accessing an admin panel for LibreChat. While you own the downloaded code, you are expressly prohibited from reselling, redistributing, or otherwise transferring the code to third parties without explicit permission from LibreChat.
|
||||
|
||||
## 2. User Data
|
||||
|
||||
We collect personal data, such as your name, email address, and payment information, as described in our Privacy Policy. This information is collected to provide and improve our services, process transactions, and communicate with you.
|
||||
|
||||
## 3. Non-Personal Data Collection
|
||||
|
||||
The Website uses cookies to enhance user experience, analyze site usage, and facilitate certain functionalities. By using the Website, you consent to the use of cookies in accordance with our Privacy Policy.
|
||||
|
||||
## 4. Use of the Website
|
||||
|
||||
You agree to use the Website only for lawful purposes and in a manner that does not infringe the rights of, restrict, or inhibit anyone else's use and enjoyment of the Website. Prohibited behavior includes harassing or causing distress or inconvenience to any person, transmitting obscene or offensive content, or disrupting the normal flow of dialogue within the Website.
|
||||
|
||||
## 5. Governing Law
|
||||
|
||||
These Terms shall be governed by and construed in accordance with the laws of the United States, without giving effect to any principles of conflicts of law.
|
||||
|
||||
## 6. Changes to the Terms
|
||||
|
||||
We reserve the right to modify these Terms at any time. We will notify users of any changes by email. Your continued use of the Website after such changes have been notified will constitute your consent to such changes.
|
||||
|
||||
## 7. Contact Information
|
||||
|
||||
If you have any questions about these Terms, please contact us at contact@librechat.ai.
|
||||
|
||||
By using the Website, you acknowledge that you have read these Terms of Service and agree to be bound by them.
|
||||
|
||||
modelSelect: true
|
||||
parameters: true
|
||||
sidePanel: true
|
||||
presets: true
|
||||
prompts: true
|
||||
bookmarks: true
|
||||
multiConvo: true
|
||||
agents: true
|
||||
peoplePicker:
|
||||
users: true
|
||||
groups: true
|
||||
roles: true
|
||||
marketplace:
|
||||
use: true
|
||||
fileCitations: true
|
||||
# Temporary chat retention period in hours (default: 720, min: 1, max: 8760)
|
||||
# temporaryChatRetention: 1
|
||||
|
||||
# Example Cloudflare turnstile (optional)
|
||||
#turnstile:
|
||||
# siteKey: "your-site-key-here"
|
||||
# options:
|
||||
# language: "auto" # "auto" or an ISO 639-1 language code (e.g. en)
|
||||
# size: "normal" # Options: "normal", "compact", "flexible", or "invisible"
|
||||
|
||||
# Example Registration Object Structure (optional)
|
||||
registration:
|
||||
socialLogins: ['openid']
|
||||
# allowedDomains:
|
||||
# - "gmail.com"
|
||||
|
||||
# Example Balance settings
|
||||
# balance:
|
||||
# enabled: false
|
||||
# startBalance: 20000
|
||||
# autoRefillEnabled: false
|
||||
# refillIntervalValue: 30
|
||||
# refillIntervalUnit: 'days'
|
||||
# refillAmount: 10000
|
||||
|
||||
# Example Transactions settings
|
||||
# Controls whether to save transaction records to the database
|
||||
# Default is true (enabled)
|
||||
transactions:
|
||||
enabled: true
|
||||
# Note: If balance.enabled is true, transactions will always be enabled
|
||||
# regardless of this setting to ensure balance tracking works correctly
|
||||
|
||||
speech:
|
||||
speechTab:
|
||||
conversationMode: true
|
||||
advancedMode: true
|
||||
speechToText:
|
||||
engineSTT: "external"
|
||||
languageSTT: "English (US)"
|
||||
autoTranscribeAudio: true
|
||||
decibelValue: -45
|
||||
autoSendText: 0
|
||||
textToSpeech:
|
||||
engineTTS: "external"
|
||||
voice: "alloy"
|
||||
languageTTS: "en"
|
||||
automaticPlayback: true
|
||||
playbackRate: 1.0
|
||||
cacheTTS: true
|
||||
tts:
|
||||
elevenlabs:
|
||||
apiKey: '${TTS_API_KEY}'
|
||||
model: 'eleven_multilingual_v2'
|
||||
voices: ['pNInz6obpgDQGcFmaJgB', 'EXAVITQu4vr4xnSDxMaL', 'JBFqnCBsd6RMkjVDRZzb', 'Xb7hH8MSUJpSbSDYk0k2']
|
||||
# Adam, Sarah, George, Alice
|
||||
stt:
|
||||
openai:
|
||||
apiKey: '${STT_API_KEY}'
|
||||
model: 'whisper-1'
|
||||
# rateLimits:
|
||||
# fileUploads:
|
||||
# ipMax: 100
|
||||
# ipWindowInMinutes: 60 # Rate limit window for file uploads per IP
|
||||
# userMax: 50
|
||||
# userWindowInMinutes: 60 # Rate limit window for file uploads per user
|
||||
# conversationsImport:
|
||||
# ipMax: 100
|
||||
# ipWindowInMinutes: 60 # Rate limit window for conversation imports per IP
|
||||
# userMax: 50
|
||||
# userWindowInMinutes: 60 # Rate limit window for conversation imports per user
|
||||
|
||||
# Example Actions Object Structure
|
||||
actions:
|
||||
allowedDomains:
|
||||
- 'swapi.dev'
|
||||
- 'librechat.ai'
|
||||
- 'google.com'
|
||||
- 'sidepiece.rip'
|
||||
- 'baked.rocks'
|
||||
- 'raindrop.com'
|
||||
- 'raindrop.services'
|
||||
|
||||
# Example MCP Servers Object Structure
|
||||
mcpServers:
|
||||
pieces:
|
||||
type: "streamable-http"
|
||||
url: https://pieces-mcp.baked.rocks/mcp
|
||||
timeout: 60000
|
||||
startup: false
|
||||
xpipe:
|
||||
type: "streamable-http"
|
||||
url: https://xpipe-mcp.baked.rocks/mcp
|
||||
timeout: 60000
|
||||
startup: false
|
||||
firecrawl:
|
||||
type: stdio
|
||||
command: npx
|
||||
args:
|
||||
- -y
|
||||
- firecrawl-mcp
|
||||
env:
|
||||
FIRECRAWL_API_KEY: dummy-key
|
||||
FIRECRAWL_API_URL: http://crawl.toy
|
||||
context7:
|
||||
type: "streamable-http"
|
||||
url: https://mcp.context7.com/mcp
|
||||
timeout: 60000
|
||||
startup: false
|
||||
headers:
|
||||
CONTEXT7_API_KEY: ctx7sk-f6f1b998-88a2-4e78-9d21-433545326e6c
|
||||
# everything:
|
||||
# # type: sse # type can optionally be omitted
|
||||
# url: http://localhost:3001/sse
|
||||
# timeout: 60000 # 1 minute timeout for this server, this is the default timeout for MCP servers.
|
||||
# puppeteer:
|
||||
# type: stdio
|
||||
# command: npx
|
||||
# args:
|
||||
# - -y
|
||||
# - "@modelcontextprotocol/server-puppeteer"
|
||||
# timeout: 300000 # 5 minutes timeout for this server
|
||||
filesystem:
|
||||
type: stdio
|
||||
command: npx
|
||||
args:
|
||||
- -y
|
||||
- "@modelcontextprotocol/server-filesystem"
|
||||
- /mnt/claude-scripts
|
||||
- /mnt/apps
|
||||
- /mnt/biz-bud
|
||||
- /mnt/portainer
|
||||
- /mnt/repos
|
||||
# iconPath: /app/client/public/assets/logo.svg # Fixed: use container path if logo exists
|
||||
# mcp-obsidian:
|
||||
# command: npx
|
||||
# args:
|
||||
# - -y
|
||||
# - "mcp-obsidian"
|
||||
# - /path/to/obsidian/vault
|
||||
sequential-thinking:
|
||||
url: https://server.smithery.ai/@smithery-ai/server-sequential-thinking/mcp
|
||||
timeout: 60000
|
||||
|
||||
# Definition of custom endpoints
|
||||
endpoints:
|
||||
assistants:
|
||||
disableBuilder: false # Disable Assistants Builder Interface by setting to `true`
|
||||
pollIntervalMs: 3000 # Polling interval for checking assistant updates
|
||||
timeoutMs: 180000 # Timeout for assistant operations
|
||||
# # Should only be one or the other, either `supportedIds` or `excludedIds`
|
||||
# supportedIds: ["asst_supportedAssistantId1", "asst_supportedAssistantId2"]
|
||||
# # excludedIds: ["asst_excludedAssistantId"]
|
||||
# # Only show assistants that the user created or that were created externally (e.g. in Assistants playground).
|
||||
privateAssistants: false # Does not work with `supportedIds` or `excludedIds`
|
||||
# # (optional) Models that support retrieval, will default to latest known OpenAI models that support the feature
|
||||
retrievalModels: ["openai/gpt-5.1"]
|
||||
# # (optional) Assistant Capabilities available to all users. Omit the ones you wish to exclude. Defaults to list below.
|
||||
capabilities: ["code_interpreter", "retrieval", "actions", "tools", "image_vision"]
|
||||
agents:
|
||||
# # (optional) Default recursion depth for agents, defaults to 25
|
||||
recursionLimit: 50
|
||||
# # (optional) Max recursion depth for agents, defaults to 25
|
||||
maxRecursionLimit: 100
|
||||
# # (optional) Disable the builder interface for agents
|
||||
disableBuilder: false
|
||||
# # (optional) Maximum total citations to include in agent responses, defaults to 30
|
||||
maxCitations: 30
|
||||
# # (optional) Maximum citations per file to include in agent responses, defaults to 7
|
||||
maxCitationsPerFile: 7
|
||||
# # (optional) Minimum relevance score for sources to be included in responses, defaults to 0.45 (45% relevance threshold)
|
||||
# # Set to 0.0 to show all sources (no filtering), or higher like 0.7 for stricter filtering
|
||||
minRelevanceScore: 0.4
|
||||
# # (optional) Agent Capabilities available to all users. Omit the ones you wish to exclude. Defaults to list below.
|
||||
capabilities: ["execute_code", "file_search", "actions", "tools", "web_search", "ocr", "artifacts", "chain", "context"]
|
||||
allowedProviders:
|
||||
- litellm
|
||||
custom:
|
||||
# Groq Example
|
||||
- name: 'litellm'
|
||||
apiKey: 'sk-1234'
|
||||
baseURL: 'http://llm.toy'
|
||||
models:
|
||||
default:
|
||||
- 'claude-sonnet-4-5'
|
||||
- 'claude-opus-4-5'
|
||||
- 'claude-haiku-4-5'
|
||||
- 'rerank-v3.5'
|
||||
- 'deepgram/base'
|
||||
- 'deepgram/nova-3'
|
||||
- 'deepgram/nova-3-general'
|
||||
- 'deepgram/whisper'
|
||||
- 'deepgram/whisper-base'
|
||||
- 'deepgram/whisper-large'
|
||||
- 'deepgram/whisper-medium'
|
||||
- 'deepgram/whisper-small'
|
||||
- 'deepgram/whisper-tiny'
|
||||
- 'elevenlabs/scribe_v1'
|
||||
- 'fireworks_ai/glm-4p7'
|
||||
- 'gemini/imagen-4.0-ultra-generate-001'
|
||||
- 'ollama/gpt-oss:20b'
|
||||
- 'gpt-5.2'
|
||||
- 'gemini/gemini-3-pro-preview'
|
||||
- 'gemini/gemini-3-flash-preview'
|
||||
- 'gpt-realtime-mini'
|
||||
- 'text-embedding-3-large'
|
||||
- 'text-embedding-3-small'
|
||||
- 'fireworks_ai/deepseek-v3p2'
|
||||
- 'fireworks_ai/kimi-k2-instruct'
|
||||
- 'gpt-realtime'
|
||||
- 'tts-1'
|
||||
- 'tts-1-hd'
|
||||
- 'whisper-1'
|
||||
- 'fireworks_ai/qwen3-vl-235b-a22b-instruct'
|
||||
- 'fireworks_ai/gpt-oss-120b'
|
||||
- 'fireworks_ai/minimax-m2p1'
|
||||
- 'gemini/imagen-4.0-generate-001'
|
||||
- 'gemini/imagen-4.0-fast-generate-001'
|
||||
- 'fireworks_ai/glm-4p6'
|
||||
- 'fireworks_ai/kimi-k2-thinking'
|
||||
- 'fireworks_ai/qwen3-vl-235b-a22b-thinking'
|
||||
fetch: false
|
||||
titleConvo: true
|
||||
titleModel: 'fireworks_ai/gpt-oss-120b'
|
||||
modelDisplayLabel: 'LLM'
|
||||
# Summarize setting: Set to true to enable summarization.
|
||||
summarize: true
|
||||
# Summary Model: Specify the model to use if summarization is enabled.
|
||||
summaryModel: "fireworks_ai/qwen3-vl-235b-a22b-instruct" # Defaults to "gpt-3.5-turbo" if omitted.
|
||||
# Force Prompt setting: If true, sends a `prompt` parameter instead of `messages`.
|
||||
forcePrompt: false
|
||||
dropParams: ['stop', 'user', 'frequency_penalty', 'presence_penalty']
|
||||
|
||||
# - name: 'groq'
|
||||
# apiKey: '${GROQ_API_KEY}'
|
||||
# baseURL: 'https://api.groq.com/openai/v1/'
|
||||
# models:
|
||||
# default:
|
||||
# - 'llama3-70b-8192'
|
||||
# - 'llama3-8b-8192'
|
||||
# - 'llama2-70b-4096'
|
||||
# - 'mixtral-8x7b-32768'
|
||||
# - 'gemma-7b-it'
|
||||
# fetch: false
|
||||
# titleConvo: true
|
||||
# titleModel: 'mixtral-8x7b-32768'
|
||||
# modelDisplayLabel: 'groq'
|
||||
|
||||
# # Mistral AI Example
|
||||
# - name: 'Mistral' # Unique name for the endpoint
|
||||
# # For `apiKey` and `baseURL`, you can use environment variables that you define.
|
||||
# # recommended environment variables:
|
||||
# apiKey: '${MISTRAL_API_KEY}'
|
||||
# baseURL: 'https://api.mistral.ai/v1'
|
||||
|
||||
# # Models configuration
|
||||
# models:
|
||||
# # List of default models to use. At least one value is required.
|
||||
# default: ['mistral-tiny', 'mistral-small', 'mistral-medium']
|
||||
# # Fetch option: Set to true to fetch models from API.
|
||||
# fetch: true # Defaults to false.
|
||||
|
||||
# # Optional configurations
|
||||
|
||||
# # Title Conversation setting
|
||||
# titleConvo: true # Set to true to enable title conversation
|
||||
|
||||
# # Title Method: Choose between "completion" or "functions".
|
||||
# # titleMethod: "completion" # Defaults to "completion" if omitted.
|
||||
|
||||
# # Title Model: Specify the model to use for titles.
|
||||
# titleModel: 'mistral-tiny' # Defaults to "gpt-3.5-turbo" if omitted.
|
||||
|
||||
# # Summarize setting: Set to true to enable summarization.
|
||||
# # summarize: false
|
||||
|
||||
# # Summary Model: Specify the model to use if summarization is enabled.
|
||||
# # summaryModel: "mistral-tiny" # Defaults to "gpt-3.5-turbo" if omitted.
|
||||
|
||||
# # Force Prompt setting: If true, sends a `prompt` parameter instead of `messages`.
|
||||
# # forcePrompt: false
|
||||
|
||||
# # The label displayed for the AI model in messages.
|
||||
# modelDisplayLabel: 'Mistral' # Default is "AI" when not set.
|
||||
|
||||
# # Add additional parameters to the request. Default params will be overwritten.
|
||||
# # addParams:
|
||||
# # safe_prompt: true # This field is specific to Mistral AI: https://docs.mistral.ai/api/
|
||||
|
||||
# # Drop Default params parameters from the request. See default params in guide linked below.
|
||||
# # NOTE: For Mistral, it is necessary to drop the following parameters or you will encounter a 422 Error:
|
||||
# dropParams: ['stop', 'user', 'frequency_penalty', 'presence_penalty']
|
||||
|
||||
# # OpenRouter Example
|
||||
# - name: 'OpenRouter'
|
||||
# # For `apiKey` and `baseURL`, you can use environment variables that you define.
|
||||
# # recommended environment variables:
|
||||
# apiKey: '${OPENROUTER_KEY}'
|
||||
# baseURL: 'https://openrouter.ai/api/v1'
|
||||
# headers:
|
||||
# x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}'
|
||||
# models:
|
||||
# default: ['meta-llama/llama-3-70b-instruct']
|
||||
# fetch: true
|
||||
# titleConvo: true
|
||||
# titleModel: 'meta-llama/llama-3-70b-instruct'
|
||||
# # Recommended: Drop the stop parameter from the request as Openrouter models use a variety of stop tokens.
|
||||
# dropParams: ['stop']
|
||||
# modelDisplayLabel: 'OpenRouter'
|
||||
|
||||
# # Helicone Example
|
||||
# - name: 'Helicone'
|
||||
# # For `apiKey` and `baseURL`, you can use environment variables that you define.
|
||||
# # recommended environment variables:
|
||||
# apiKey: '${HELICONE_KEY}'
|
||||
# baseURL: 'https://ai-gateway.helicone.ai'
|
||||
# headers:
|
||||
# x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}'
|
||||
# models:
|
||||
# default: ['gpt-4o-mini', 'claude-4.5-sonnet', 'llama-3.1-8b-instruct', 'gemini-2.5-flash-lite']
|
||||
# fetch: true
|
||||
# titleConvo: true
|
||||
# titleModel: 'gpt-4o-mini'
|
||||
# modelDisplayLabel: 'Helicone'
|
||||
# iconURL: https://marketing-assets-helicone.s3.us-west-2.amazonaws.com/helicone.png
|
||||
|
||||
# # Portkey AI Example
|
||||
# - name: 'Portkey'
|
||||
# apiKey: 'dummy'
|
||||
# baseURL: 'https://api.portkey.ai/v1'
|
||||
# headers:
|
||||
# x-portkey-api-key: '${PORTKEY_API_KEY}'
|
||||
# x-portkey-virtual-key: '${PORTKEY_OPENAI_VIRTUAL_KEY}'
|
||||
# models:
|
||||
# default: ['gpt-4o-mini', 'gpt-4o', 'chatgpt-4o-latest']
|
||||
# fetch: true
|
||||
# titleConvo: true
|
||||
# titleModel: 'current_model'
|
||||
# summarize: false
|
||||
# summaryModel: 'current_model'
|
||||
# forcePrompt: false
|
||||
# modelDisplayLabel: 'Portkey'
|
||||
# iconURL: https://images.crunchbase.com/image/upload/c_pad,f_auto,q_auto:eco,dpr_1/rjqy7ghvjoiu4cd1xjbf
|
||||
# Example modelSpecs configuration showing grouping options
|
||||
# The 'group' field organizes model specs in the UI selector:
|
||||
# - If 'group' matches an endpoint name (e.g., "openAI", "groq"), the spec appears nested under that endpoint
|
||||
# - If 'group' is a custom name (doesn't match any endpoint), it creates a separate collapsible section
|
||||
# - If 'group' is omitted, the spec appears as a standalone item at the top level
|
||||
# modelSpecs:
|
||||
# list:
|
||||
# # Example 1: Nested under an endpoint (grouped with openAI endpoint)
|
||||
# - name: "gpt-4o"
|
||||
# label: "GPT-4 Optimized"
|
||||
# description: "Most capable GPT-4 model with multimodal support"
|
||||
# group: "openAI" # String value matching the endpoint name
|
||||
# preset:
|
||||
# endpoint: "openAI"
|
||||
# model: "gpt-4o"
|
||||
#
|
||||
# # Example 2: Nested under a custom endpoint (grouped with groq endpoint)
|
||||
# - name: "llama3-70b-8192"
|
||||
# label: "Llama 3 70B"
|
||||
# description: "Fastest inference available - great for quick responses"
|
||||
# group: "groq" # String value matching your custom endpoint name from endpoints.custom
|
||||
# preset:
|
||||
# endpoint: "groq"
|
||||
# model: "llama3-70b-8192"
|
||||
#
|
||||
# # Example 3: Custom group (creates a separate collapsible section)
|
||||
# - name: "coding-assistant"
|
||||
# label: "Coding Assistant"
|
||||
# description: "Specialized for coding tasks"
|
||||
# group: "my-assistants" # Custom string - doesn't match any endpoint, so creates its own group
|
||||
# preset:
|
||||
# endpoint: "openAI"
|
||||
# model: "gpt-4o"
|
||||
# instructions: "You are an expert coding assistant..."
|
||||
# temperature: 0.3
|
||||
#
|
||||
# - name: "writing-assistant"
|
||||
# label: "Writing Assistant"
|
||||
# description: "Specialized for creative writing"
|
||||
# group: "my-assistants" # Same custom group name - both specs appear in same section
|
||||
# preset:
|
||||
# endpoint: "anthropic"
|
||||
# model: "claude-sonnet-4"
|
||||
# instructions: "You are a creative writing expert..."
|
||||
#
|
||||
# # Example 4: Standalone (no group - appears at top level)
|
||||
# - name: "general-assistant"
|
||||
# label: "General Assistant"
|
||||
# description: "General purpose assistant"
|
||||
# # No 'group' field - appears as standalone item at top level (not nested)
|
||||
# preset:
|
||||
# endpoint: "openAI"
|
||||
# model: "gpt-4o-mini"
|
||||
|
||||
fileConfig:
|
||||
endpoints:
|
||||
assistants:
|
||||
fileLimit: 5
|
||||
fileSizeLimit: 10 # Maximum size for an individual file in MB
|
||||
totalSizeLimit: 50 # Maximum total size for all files in a single request in MB
|
||||
supportedMimeTypes:
|
||||
- "image/.*"
|
||||
- "application/pdf"
|
||||
openAI:
|
||||
disabled: true # Disables file uploading to the OpenAI endpoint
|
||||
default:
|
||||
totalSizeLimit: 100
|
||||
fileSizeLimit: 100
|
||||
fileLimit: 10
|
||||
# bifrost:
|
||||
# fileLimit: 25
|
||||
# fileSizeLimit: 50
|
||||
serverFileSizeLimit: 1000 # Global server file size limit in MB
|
||||
fileTokenLimit: 100000
|
||||
avatarSizeLimit: 2 # Limit for user avatar image size in MB
|
||||
imageGeneration: # Image Gen settings, either percentage or px
|
||||
percentage: 100
|
||||
px: 1024
|
||||
ocr:
|
||||
supportedMimeTypes:
|
||||
- "^image/(jpeg|gif|png|webp|heic|heif)$"
|
||||
- "^application/pdf$"
|
||||
- "^application/vnd\\.openxmlformats-officedocument\\.(wordprocessingml\\.document|presentationml\\.presentation|spreadsheetml\\.sheet)$"
|
||||
- "^application/vnd\\.ms-(word|powerpoint|excel)$"
|
||||
- "^application/epub\\+zip$"
|
||||
text:
|
||||
supportedMimeTypes:
|
||||
- "^text/(plain|markdown|csv|json|xml|html|css|javascript|typescript|x-python|x-java|x-csharp|x-php|x-ruby|x-go|x-rust|x-kotlin|x-swift|x-scala|x-perl|x-lua|x-shell|x-sql|x-yaml|x-toml)$"
|
||||
stt:
|
||||
supportedMimeTypes:
|
||||
- "^audio/(mp3|mpeg|mpeg3|wav|wave|x-wav|ogg|vorbis|mp4|x-m4a|flac|x-flac|webm)$"
|
||||
# Client-side image resizing to prevent upload errors
|
||||
clientImageResize:
|
||||
enabled: true # Enable/disable client-side image resizing (default: false)
|
||||
maxWidth: 1900 # Maximum width for resized images (default: 1900)
|
||||
maxHeight: 1900 # Maximum height for resized images (default: 1900)
|
||||
quality: 0.92 # JPEG quality for compression (0.0-1.0, default: 0.92)
|
||||
# See the Custom Configuration Guide for more information on Assistants Config:
|
||||
# https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/assistants_endpoint
|
||||
|
||||
# Web Search Configuration (optional)
|
||||
webSearch:
|
||||
rerankerType: 'cohere'
|
||||
# Jina Reranking Configuration
|
||||
# jinaApiKey: '${JINA_API_KEY}' # Your Jina API key
|
||||
# jinaApiUrl: '${JINA_API_URL}' # Custom Jina API URL (optional, defaults to https://api.jina.ai/v1/rerank)
|
||||
# Other rerankers
|
||||
cohereApiKey: '${COHERE_API_KEY}'
|
||||
# Search providers
|
||||
searchProvider: "searxng"
|
||||
# serperApiKey: '${SERPER_API_KEY}'
|
||||
searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}'
|
||||
# searxngApiKey: '${SEARXNG_API_KEY}'
|
||||
# Content scrapers
|
||||
scraperProvider: "firecrawl"
|
||||
firecrawlApiKey: '${FIRECRAWL_API_KEY}'
|
||||
firecrawlApiUrl: '${FIRECRAWL_API_URL}'
|
||||
firecrawlVersion: "${FIRECRAWL_VERSION}"
|
||||
# Memory configuration for user memories
|
||||
memory:
|
||||
# (optional) Disable memory functionality
|
||||
disabled: false
|
||||
# (optional) Restrict memory keys to specific values to limit memory storage and improve consistency
|
||||
validKeys: ["preferences", "work_info", "personal_info", "skills", "interests", "context"]
|
||||
# (optional) Maximum token limit for memory storage (not yet implemented for token counting)
|
||||
tokenLimit: 10000
|
||||
# (optional) Enable personalization features (defaults to true if memory is configured)
|
||||
# When false, users will not see the Personalization tab in settings
|
||||
personalize: true
|
||||
# Memory agent configuration - either use an existing agent by ID or define inline
|
||||
agent:
|
||||
# Option 1: Use existing agent by ID
|
||||
# id: "your-memory-agent-id"
|
||||
# Option 2: Define agent inline
|
||||
provider: "litellm"
|
||||
model: "fireworks_ai/qwen3-vl-30b-a3b-instruct"
|
||||
instructions: "You are a memory management assistant. Store and manage user information accurately and do not embellish the information."
|
||||
# model_parameters:
|
||||
# temperature: 0.1
|
||||
431
package-lock.json
generated
431
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "LibreChat",
|
||||
"version": "v0.8.1-rc1",
|
||||
"version": "v0.8.1-rc2",
|
||||
"description": "",
|
||||
"workspaces": [
|
||||
"api",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/api",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"type": "commonjs",
|
||||
"description": "MCP services for LibreChat",
|
||||
"main": "dist/index.js",
|
||||
@@ -84,7 +84,7 @@
|
||||
"@azure/storage-blob": "^12.27.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/core": "^0.3.79",
|
||||
"@librechat/agents": "^3.0.32",
|
||||
"@librechat/agents": "^3.0.36",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.21.0",
|
||||
"axios": "^1.12.1",
|
||||
|
||||
@@ -16,9 +16,9 @@ import { resolveHeaders, createSafeUser } from '~/utils/env';
|
||||
|
||||
const customProviders = new Set([
|
||||
Providers.XAI,
|
||||
Providers.OLLAMA,
|
||||
Providers.DEEPSEEK,
|
||||
Providers.OPENROUTER,
|
||||
KnownEndpoints.ollama,
|
||||
]);
|
||||
|
||||
export function getReasoningKey(
|
||||
|
||||
@@ -394,6 +394,34 @@ describe('findOpenIDUser', () => {
|
||||
expect(mockFindUser).toHaveBeenCalledWith({ email: 'user@example.com' });
|
||||
});
|
||||
|
||||
it('should pass email to findUser for case-insensitive lookup (findUser handles normalization)', async () => {
|
||||
const mockUser: IUser = {
|
||||
_id: 'user123',
|
||||
provider: 'openid',
|
||||
openidId: 'openid_456',
|
||||
email: 'user@example.com',
|
||||
username: 'testuser',
|
||||
} as IUser;
|
||||
|
||||
mockFindUser
|
||||
.mockResolvedValueOnce(null) // Primary condition fails
|
||||
.mockResolvedValueOnce(mockUser); // Email search succeeds
|
||||
|
||||
const result = await findOpenIDUser({
|
||||
openidId: 'openid_123',
|
||||
findUser: mockFindUser,
|
||||
email: 'User@Example.COM',
|
||||
});
|
||||
|
||||
/** Email is passed as-is; findUser implementation handles normalization */
|
||||
expect(mockFindUser).toHaveBeenNthCalledWith(2, { email: 'User@Example.COM' });
|
||||
expect(result).toEqual({
|
||||
user: mockUser,
|
||||
error: null,
|
||||
migration: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle findUser throwing an error', async () => {
|
||||
mockFindUser.mockRejectedValueOnce(new Error('Database error'));
|
||||
|
||||
|
||||
@@ -121,9 +121,12 @@ export function getSafetySettings(
|
||||
export function getGoogleConfig(
|
||||
credentials: string | t.GoogleCredentials | undefined,
|
||||
options: t.GoogleConfigOptions = {},
|
||||
acceptRawApiKey = false,
|
||||
) {
|
||||
let creds: t.GoogleCredentials = {};
|
||||
if (typeof credentials === 'string') {
|
||||
if (acceptRawApiKey && typeof credentials === 'string') {
|
||||
creds[AuthKeys.GOOGLE_API_KEY] = credentials;
|
||||
} else if (typeof credentials === 'string') {
|
||||
try {
|
||||
creds = JSON.parse(credentials);
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -69,6 +69,26 @@ describe('getOpenAIConfig - Google Compatibility', () => {
|
||||
expect(result.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter out googleSearch when web_search is only in modelOptions (not explicitly in addParams/defaultParams)', () => {
|
||||
const apiKey = JSON.stringify({ GOOGLE_API_KEY: 'test-google-key' });
|
||||
const endpoint = 'Gemini (Custom)';
|
||||
const options = {
|
||||
modelOptions: {
|
||||
model: 'gemini-2.0-flash-exp',
|
||||
web_search: true,
|
||||
},
|
||||
customParams: {
|
||||
defaultParamsEndpoint: 'google',
|
||||
},
|
||||
reverseProxyUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
};
|
||||
|
||||
const result = getOpenAIConfig(apiKey, options, endpoint);
|
||||
|
||||
/** googleSearch should be filtered out since web_search was not explicitly added via addParams or defaultParams */
|
||||
expect(result.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle web_search with mixed Google and OpenAI params in addParams', () => {
|
||||
const apiKey = JSON.stringify({ GOOGLE_API_KEY: 'test-google-key' });
|
||||
const endpoint = 'Gemini (Custom)';
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('getOpenAIConfig', () => {
|
||||
|
||||
it('should apply model options', () => {
|
||||
const modelOptions = {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
};
|
||||
@@ -34,14 +34,11 @@ describe('getOpenAIConfig', () => {
|
||||
const result = getOpenAIConfig(mockApiKey, { modelOptions });
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
modelKwargs: {
|
||||
max_completion_tokens: 1000,
|
||||
},
|
||||
maxTokens: 1000,
|
||||
});
|
||||
expect((result.llmConfig as Record<string, unknown>).max_tokens).toBeUndefined();
|
||||
expect((result.llmConfig as Record<string, unknown>).maxTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should separate known and unknown params from addParams', () => {
|
||||
@@ -286,7 +283,7 @@ describe('getOpenAIConfig', () => {
|
||||
|
||||
it('should ignore non-boolean web_search values in addParams', () => {
|
||||
const modelOptions = {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
web_search: true,
|
||||
};
|
||||
|
||||
@@ -399,7 +396,7 @@ describe('getOpenAIConfig', () => {
|
||||
|
||||
it('should handle verbosity parameter in modelKwargs', () => {
|
||||
const modelOptions = {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
verbosity: Verbosity.high,
|
||||
};
|
||||
@@ -407,7 +404,7 @@ describe('getOpenAIConfig', () => {
|
||||
const result = getOpenAIConfig(mockApiKey, { modelOptions });
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
});
|
||||
expect(result.llmConfig.modelKwargs).toEqual({
|
||||
@@ -417,7 +414,7 @@ describe('getOpenAIConfig', () => {
|
||||
|
||||
it('should allow addParams to override verbosity in modelKwargs', () => {
|
||||
const modelOptions = {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
verbosity: Verbosity.low,
|
||||
};
|
||||
|
||||
@@ -451,7 +448,7 @@ describe('getOpenAIConfig', () => {
|
||||
|
||||
it('should nest verbosity under text when useResponsesApi is enabled', () => {
|
||||
const modelOptions = {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
verbosity: Verbosity.low,
|
||||
useResponsesApi: true,
|
||||
@@ -460,7 +457,7 @@ describe('getOpenAIConfig', () => {
|
||||
const result = getOpenAIConfig(mockApiKey, { modelOptions });
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
useResponsesApi: true,
|
||||
});
|
||||
@@ -496,7 +493,6 @@ describe('getOpenAIConfig', () => {
|
||||
it('should move maxTokens to modelKwargs.max_completion_tokens for GPT-5+ models', () => {
|
||||
const modelOptions = {
|
||||
model: 'gpt-5',
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048,
|
||||
};
|
||||
|
||||
@@ -504,7 +500,6 @@ describe('getOpenAIConfig', () => {
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
temperature: 0.7,
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBeUndefined();
|
||||
expect(result.llmConfig.modelKwargs).toEqual({
|
||||
@@ -1684,7 +1679,7 @@ describe('getOpenAIConfig', () => {
|
||||
it('should not override existing modelOptions with defaultParams', () => {
|
||||
const result = getOpenAIConfig(mockApiKey, {
|
||||
modelOptions: {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.9,
|
||||
},
|
||||
customParams: {
|
||||
@@ -1697,7 +1692,7 @@ describe('getOpenAIConfig', () => {
|
||||
});
|
||||
|
||||
expect(result.llmConfig.temperature).toBe(0.9);
|
||||
expect(result.llmConfig.modelKwargs?.max_completion_tokens).toBe(1000);
|
||||
expect(result.llmConfig.maxTokens).toBe(1000);
|
||||
});
|
||||
|
||||
it('should allow addParams to override defaultParams', () => {
|
||||
@@ -1845,7 +1840,7 @@ describe('getOpenAIConfig', () => {
|
||||
it('should preserve order: defaultParams < addParams < modelOptions', () => {
|
||||
const result = getOpenAIConfig(mockApiKey, {
|
||||
modelOptions: {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.9,
|
||||
},
|
||||
customParams: {
|
||||
@@ -1863,7 +1858,7 @@ describe('getOpenAIConfig', () => {
|
||||
|
||||
expect(result.llmConfig.temperature).toBe(0.9);
|
||||
expect(result.llmConfig.topP).toBe(0.8);
|
||||
expect(result.llmConfig.modelKwargs?.max_completion_tokens).toBe(500);
|
||||
expect(result.llmConfig.maxTokens).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,23 +77,29 @@ export function getOpenAIConfig(
|
||||
headers = Object.assign(headers ?? {}, transformed.configOptions?.defaultHeaders);
|
||||
}
|
||||
} else if (isGoogle) {
|
||||
const googleResult = getGoogleConfig(apiKey, {
|
||||
modelOptions,
|
||||
reverseProxyUrl: baseURL ?? undefined,
|
||||
authHeader: true,
|
||||
addParams,
|
||||
dropParams,
|
||||
defaultParams,
|
||||
});
|
||||
const googleResult = getGoogleConfig(
|
||||
apiKey,
|
||||
{
|
||||
modelOptions,
|
||||
reverseProxyUrl: baseURL ?? undefined,
|
||||
authHeader: true,
|
||||
addParams,
|
||||
dropParams,
|
||||
defaultParams,
|
||||
},
|
||||
true,
|
||||
);
|
||||
/** Transform handles addParams/dropParams - it knows about OpenAI params */
|
||||
const transformed = transformToOpenAIConfig({
|
||||
addParams,
|
||||
dropParams,
|
||||
defaultParams,
|
||||
tools: googleResult.tools,
|
||||
llmConfig: googleResult.llmConfig,
|
||||
fromEndpoint: EModelEndpoint.google,
|
||||
});
|
||||
llmConfig = transformed.llmConfig;
|
||||
tools = googleResult.tools;
|
||||
tools = transformed.tools;
|
||||
} else {
|
||||
const openaiResult = getOpenAILLMConfig({
|
||||
azure,
|
||||
|
||||
602
packages/api/src/endpoints/openai/llm.spec.ts
Normal file
602
packages/api/src/endpoints/openai/llm.spec.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
import {
|
||||
Verbosity,
|
||||
EModelEndpoint,
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
} from 'librechat-data-provider';
|
||||
import { getOpenAILLMConfig, extractDefaultParams, applyDefaultParams } from './llm';
|
||||
import type * as t from '~/types';
|
||||
|
||||
describe('getOpenAILLMConfig', () => {
|
||||
describe('Basic Configuration', () => {
|
||||
it('should create a basic configuration with required fields', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('apiKey', 'test-api-key');
|
||||
expect(result.llmConfig).toHaveProperty('model', 'gpt-4');
|
||||
expect(result.llmConfig).toHaveProperty('streaming', true);
|
||||
expect(result.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle model options including temperature and penalties', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0.5,
|
||||
presence_penalty: 0.3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
|
||||
expect(result.llmConfig).toHaveProperty('frequencyPenalty', 0.5);
|
||||
expect(result.llmConfig).toHaveProperty('presencePenalty', 0.3);
|
||||
});
|
||||
|
||||
it('should handle max_tokens conversion to maxTokens', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 4096,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
|
||||
expect(result.llmConfig).not.toHaveProperty('max_tokens');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAI Reasoning Models (o1/o3/gpt-5)', () => {
|
||||
const reasoningModels = [
|
||||
'o1',
|
||||
'o1-mini',
|
||||
'o1-preview',
|
||||
'o1-pro',
|
||||
'o3',
|
||||
'o3-mini',
|
||||
'gpt-5',
|
||||
'gpt-5-pro',
|
||||
'gpt-5-turbo',
|
||||
];
|
||||
|
||||
const excludedParams = [
|
||||
'frequencyPenalty',
|
||||
'presencePenalty',
|
||||
'temperature',
|
||||
'topP',
|
||||
'logitBias',
|
||||
'n',
|
||||
'logprobs',
|
||||
];
|
||||
|
||||
it.each(reasoningModels)(
|
||||
'should exclude unsupported parameters for reasoning model: %s',
|
||||
(model) => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model,
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0.5,
|
||||
presence_penalty: 0.3,
|
||||
topP: 0.9,
|
||||
logitBias: { '50256': -100 },
|
||||
n: 2,
|
||||
logprobs: true,
|
||||
} as Partial<t.OpenAIParameters>,
|
||||
});
|
||||
|
||||
excludedParams.forEach((param) => {
|
||||
expect(result.llmConfig).not.toHaveProperty(param);
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('model', model);
|
||||
expect(result.llmConfig).toHaveProperty('streaming', true);
|
||||
},
|
||||
);
|
||||
|
||||
it('should preserve maxTokens for reasoning models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'o1',
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
});
|
||||
|
||||
it('should preserve other valid parameters for reasoning models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'o1',
|
||||
max_tokens: 8192,
|
||||
stop: ['END'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 8192);
|
||||
expect(result.llmConfig).toHaveProperty('stop', ['END']);
|
||||
});
|
||||
|
||||
it('should handle GPT-5 max_tokens conversion to max_completion_tokens', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-5',
|
||||
max_tokens: 8192,
|
||||
stop: ['END'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('max_completion_tokens', 8192);
|
||||
expect(result.llmConfig).not.toHaveProperty('maxTokens');
|
||||
expect(result.llmConfig).toHaveProperty('stop', ['END']);
|
||||
});
|
||||
|
||||
it('should combine user dropParams with reasoning exclusion params', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'o3-mini',
|
||||
temperature: 0.7,
|
||||
stop: ['END'],
|
||||
},
|
||||
dropParams: ['stop'],
|
||||
});
|
||||
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
expect(result.llmConfig).not.toHaveProperty('stop');
|
||||
});
|
||||
|
||||
it('should NOT exclude parameters for non-reasoning models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4-turbo',
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0.5,
|
||||
presence_penalty: 0.3,
|
||||
topP: 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
|
||||
expect(result.llmConfig).toHaveProperty('frequencyPenalty', 0.5);
|
||||
expect(result.llmConfig).toHaveProperty('presencePenalty', 0.3);
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
|
||||
it('should NOT exclude parameters for gpt-5.x versioned models (they support sampling params)', () => {
|
||||
const versionedModels = ['gpt-5.1', 'gpt-5.1-turbo', 'gpt-5.2', 'gpt-5.5-preview'];
|
||||
|
||||
versionedModels.forEach((model) => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model,
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0.5,
|
||||
presence_penalty: 0.3,
|
||||
topP: 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
|
||||
expect(result.llmConfig).toHaveProperty('frequencyPenalty', 0.5);
|
||||
expect(result.llmConfig).toHaveProperty('presencePenalty', 0.3);
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT exclude parameters for gpt-5-chat (it supports sampling params)', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-5-chat',
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0.5,
|
||||
presence_penalty: 0.3,
|
||||
topP: 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
|
||||
expect(result.llmConfig).toHaveProperty('frequencyPenalty', 0.5);
|
||||
expect(result.llmConfig).toHaveProperty('presencePenalty', 0.3);
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
|
||||
it('should handle reasoning models with reasoning_effort parameter', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
modelOptions: {
|
||||
model: 'o1',
|
||||
reasoning_effort: ReasoningEffort.high,
|
||||
temperature: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high);
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAI Web Search Models', () => {
|
||||
it('should exclude parameters for gpt-4o search models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4o-search-preview',
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
seed: 42,
|
||||
} as Partial<t.OpenAIParameters>,
|
||||
});
|
||||
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
expect(result.llmConfig).not.toHaveProperty('top_p');
|
||||
expect(result.llmConfig).not.toHaveProperty('seed');
|
||||
});
|
||||
|
||||
it('should preserve max_tokens for search models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4o-search',
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Web Search Functionality', () => {
|
||||
it('should enable web search with Responses API', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
web_search: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('useResponsesApi', true);
|
||||
expect(result.tools).toContainEqual({ type: 'web_search' });
|
||||
});
|
||||
|
||||
it('should handle web search with OpenRouter', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
useOpenRouter: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
web_search: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('plugins', [{ id: 'web' }]);
|
||||
expect(result.llmConfig).toHaveProperty('include_reasoning', true);
|
||||
});
|
||||
|
||||
it('should disable web search via dropParams', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
web_search: true,
|
||||
},
|
||||
dropParams: ['web_search'],
|
||||
});
|
||||
|
||||
expect(result.tools).not.toContainEqual({ type: 'web_search' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GPT-5 max_tokens Handling', () => {
|
||||
it('should convert maxTokens to max_completion_tokens for GPT-5 models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-5',
|
||||
max_tokens: 8192,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('max_completion_tokens', 8192);
|
||||
expect(result.llmConfig).not.toHaveProperty('maxTokens');
|
||||
});
|
||||
|
||||
it('should convert maxTokens to max_output_tokens for GPT-5 with Responses API', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-5',
|
||||
max_tokens: 8192,
|
||||
},
|
||||
addParams: {
|
||||
useResponsesApi: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('max_output_tokens', 8192);
|
||||
expect(result.llmConfig).not.toHaveProperty('maxTokens');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reasoning Parameters', () => {
|
||||
it('should handle reasoning_effort for OpenAI endpoint', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
modelOptions: {
|
||||
model: 'o1',
|
||||
reasoning_effort: ReasoningEffort.high,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high);
|
||||
});
|
||||
|
||||
it('should use reasoning object for non-OpenAI endpoints', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
endpoint: 'custom',
|
||||
modelOptions: {
|
||||
model: 'o1',
|
||||
reasoning_effort: ReasoningEffort.high,
|
||||
reasoning_summary: ReasoningSummary.concise,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('reasoning');
|
||||
expect(result.llmConfig.reasoning).toEqual({
|
||||
effort: ReasoningEffort.high,
|
||||
summary: ReasoningSummary.concise,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use reasoning object when useResponsesApi is true', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
modelOptions: {
|
||||
model: 'o1',
|
||||
reasoning_effort: ReasoningEffort.medium,
|
||||
reasoning_summary: ReasoningSummary.detailed,
|
||||
},
|
||||
addParams: {
|
||||
useResponsesApi: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('reasoning');
|
||||
expect(result.llmConfig.reasoning).toEqual({
|
||||
effort: ReasoningEffort.medium,
|
||||
summary: ReasoningSummary.detailed,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default and Add Parameters', () => {
|
||||
it('should apply default parameters when fields are undefined', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
defaultParams: {
|
||||
temperature: 0.5,
|
||||
topP: 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.5);
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
|
||||
it('should NOT override existing values with default parameters', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
temperature: 0.8,
|
||||
},
|
||||
defaultParams: {
|
||||
temperature: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.8);
|
||||
});
|
||||
|
||||
it('should apply addParams and override defaults', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
defaultParams: {
|
||||
temperature: 0.5,
|
||||
},
|
||||
addParams: {
|
||||
temperature: 0.9,
|
||||
seed: 42,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.9);
|
||||
expect(result.llmConfig).toHaveProperty('seed', 42);
|
||||
});
|
||||
|
||||
it('should handle unknown params via modelKwargs', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
addParams: {
|
||||
custom_param: 'custom_value',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('custom_param', 'custom_value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drop Parameters', () => {
|
||||
it('should drop specified parameters', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
},
|
||||
dropParams: ['temperature'],
|
||||
});
|
||||
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenRouter Configuration', () => {
|
||||
it('should include include_reasoning for OpenRouter', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
useOpenRouter: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('include_reasoning', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verbosity Handling', () => {
|
||||
it('should add verbosity to modelKwargs', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
verbosity: Verbosity.high,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('verbosity', Verbosity.high);
|
||||
});
|
||||
|
||||
it('should convert verbosity to text object with Responses API', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
modelOptions: {
|
||||
model: 'gpt-4',
|
||||
verbosity: Verbosity.low,
|
||||
},
|
||||
addParams: {
|
||||
useResponsesApi: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('text', { verbosity: Verbosity.low });
|
||||
expect(result.llmConfig.modelKwargs).not.toHaveProperty('verbosity');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractDefaultParams', () => {
|
||||
it('should extract default values from param definitions', () => {
|
||||
const paramDefinitions = [
|
||||
{ key: 'temperature', default: 0.7 },
|
||||
{ key: 'maxTokens', default: 4096 },
|
||||
{ key: 'noDefault' },
|
||||
];
|
||||
|
||||
const result = extractDefaultParams(paramDefinitions);
|
||||
|
||||
expect(result).toEqual({
|
||||
temperature: 0.7,
|
||||
maxTokens: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for undefined or non-array input', () => {
|
||||
expect(extractDefaultParams(undefined)).toBeUndefined();
|
||||
expect(extractDefaultParams(null as unknown as undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = extractDefaultParams([]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDefaultParams', () => {
|
||||
it('should apply defaults only when field is undefined', () => {
|
||||
const target: Record<string, unknown> = {
|
||||
temperature: 0.8,
|
||||
maxTokens: undefined,
|
||||
};
|
||||
|
||||
const defaults = {
|
||||
temperature: 0.5,
|
||||
maxTokens: 4096,
|
||||
topP: 0.9,
|
||||
};
|
||||
|
||||
applyDefaultParams(target, defaults);
|
||||
|
||||
expect(target).toEqual({
|
||||
temperature: 0.8,
|
||||
maxTokens: 4096,
|
||||
topP: 0.9,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -259,9 +259,35 @@ export function getOpenAILLMConfig({
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: OpenAI Web Search models do not support any known parameters besides `max_tokens`
|
||||
* Note: OpenAI reasoning models (o1/o3/gpt-5) do not support temperature and other sampling parameters
|
||||
* Exception: gpt-5-chat and versioned models like gpt-5.1 DO support these parameters
|
||||
*/
|
||||
if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model as string)) {
|
||||
if (
|
||||
modelOptions.model &&
|
||||
/\b(o[13]|gpt-5)(?!\.|-chat)(?:-|$)/.test(modelOptions.model as string)
|
||||
) {
|
||||
const reasoningExcludeParams = [
|
||||
'frequencyPenalty',
|
||||
'presencePenalty',
|
||||
'temperature',
|
||||
'topP',
|
||||
'logitBias',
|
||||
'n',
|
||||
'logprobs',
|
||||
];
|
||||
|
||||
const updatedDropParams = dropParams || [];
|
||||
const combinedDropParams = [...new Set([...updatedDropParams, ...reasoningExcludeParams])];
|
||||
|
||||
combinedDropParams.forEach((param) => {
|
||||
if (param in llmConfig) {
|
||||
delete llmConfig[param as keyof t.OAIClientOptions];
|
||||
}
|
||||
});
|
||||
} else if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model as string)) {
|
||||
/**
|
||||
* Note: OpenAI Web Search models do not support any known parameters besides `max_tokens`
|
||||
*/
|
||||
const searchExcludeParams = [
|
||||
'frequency_penalty',
|
||||
'presence_penalty',
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { GoogleAIToolType } from '@langchain/google-common';
|
||||
import type { ClientOptions } from '@librechat/agents';
|
||||
import type * as t from '~/types';
|
||||
import { knownOpenAIParams } from './llm';
|
||||
|
||||
const anthropicExcludeParams = new Set(['anthropicApiUrl']);
|
||||
const googleExcludeParams = new Set(['safetySettings', 'location', 'baseUrl', 'customHeaders']);
|
||||
const googleExcludeParams = new Set([
|
||||
'safetySettings',
|
||||
'location',
|
||||
'baseUrl',
|
||||
'customHeaders',
|
||||
'thinkingConfig',
|
||||
'thinkingBudget',
|
||||
'includeThoughts',
|
||||
]);
|
||||
|
||||
/** Google-specific tool types that have no OpenAI-compatible equivalent */
|
||||
const googleToolsToFilter = new Set(['googleSearch']);
|
||||
|
||||
export type ConfigTools = Array<Record<string, unknown>> | Array<GoogleAIToolType>;
|
||||
|
||||
/**
|
||||
* Transforms a Non-OpenAI LLM config to an OpenAI-conformant config.
|
||||
* Non-OpenAI parameters are moved to modelKwargs.
|
||||
* Also extracts configuration options that belong in configOptions.
|
||||
* Handles addParams and dropParams for parameter customization.
|
||||
* Filters out provider-specific tools that have no OpenAI equivalent.
|
||||
*/
|
||||
export function transformToOpenAIConfig({
|
||||
tools,
|
||||
addParams,
|
||||
dropParams,
|
||||
defaultParams,
|
||||
llmConfig,
|
||||
fromEndpoint,
|
||||
}: {
|
||||
tools?: ConfigTools;
|
||||
addParams?: Record<string, unknown>;
|
||||
dropParams?: string[];
|
||||
defaultParams?: Record<string, unknown>;
|
||||
llmConfig: ClientOptions;
|
||||
fromEndpoint: string;
|
||||
}): {
|
||||
tools: ConfigTools;
|
||||
llmConfig: t.OAIClientOptions;
|
||||
configOptions: Partial<t.OpenAIConfiguration>;
|
||||
} {
|
||||
@@ -58,18 +78,9 @@ export function transformToOpenAIConfig({
|
||||
hasModelKwargs = true;
|
||||
continue;
|
||||
} else if (isGoogle && key === 'authOptions') {
|
||||
// Handle Google authOptions
|
||||
modelKwargs = Object.assign({}, modelKwargs, value as Record<string, unknown>);
|
||||
hasModelKwargs = true;
|
||||
continue;
|
||||
} else if (
|
||||
isGoogle &&
|
||||
(key === 'thinkingConfig' || key === 'thinkingBudget' || key === 'includeThoughts')
|
||||
) {
|
||||
// Handle Google thinking configuration
|
||||
modelKwargs = Object.assign({}, modelKwargs, { [key]: value });
|
||||
hasModelKwargs = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (knownOpenAIParams.has(key)) {
|
||||
@@ -121,7 +132,34 @@ export function transformToOpenAIConfig({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out provider-specific tools that have no OpenAI equivalent.
|
||||
* Exception: If web_search was explicitly enabled via addParams or defaultParams,
|
||||
* preserve googleSearch tools (pass through in Google-native format).
|
||||
*/
|
||||
const webSearchExplicitlyEnabled =
|
||||
addParams?.web_search === true || defaultParams?.web_search === true;
|
||||
|
||||
const filterGoogleTool = (tool: unknown): boolean => {
|
||||
if (!isGoogle) {
|
||||
return true;
|
||||
}
|
||||
if (typeof tool !== 'object' || tool === null) {
|
||||
return false;
|
||||
}
|
||||
const toolKeys = Object.keys(tool as Record<string, unknown>);
|
||||
const isGoogleSpecificTool = toolKeys.some((key) => googleToolsToFilter.has(key));
|
||||
/** Preserve googleSearch if web_search was explicitly enabled */
|
||||
if (isGoogleSpecificTool && webSearchExplicitlyEnabled) {
|
||||
return true;
|
||||
}
|
||||
return !isGoogleSpecificTool;
|
||||
};
|
||||
|
||||
const filteredTools = Array.isArray(tools) ? tools.filter(filterGoogleTool) : [];
|
||||
|
||||
return {
|
||||
tools: filteredTools,
|
||||
llmConfig: openAIConfig as t.OAIClientOptions,
|
||||
configOptions,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './access';
|
||||
export * from './error';
|
||||
export * from './balance';
|
||||
export * from './json';
|
||||
|
||||
158
packages/api/src/middleware/json.spec.ts
Normal file
158
packages/api/src/middleware/json.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { handleJsonParseError } from './json';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
describe('handleJsonParseError', () => {
|
||||
let req: Partial<Request>;
|
||||
let res: Partial<Response>;
|
||||
let next: NextFunction;
|
||||
let jsonSpy: jest.Mock;
|
||||
let statusSpy: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
path: '/api/test',
|
||||
method: 'POST',
|
||||
ip: '127.0.0.1',
|
||||
};
|
||||
|
||||
jsonSpy = jest.fn();
|
||||
statusSpy = jest.fn().mockReturnValue({ json: jsonSpy });
|
||||
|
||||
res = {
|
||||
status: statusSpy,
|
||||
json: jsonSpy,
|
||||
};
|
||||
|
||||
next = jest.fn();
|
||||
});
|
||||
|
||||
describe('JSON parse errors', () => {
|
||||
it('should handle JSON SyntaxError with 400 status', () => {
|
||||
const err = new SyntaxError('Unexpected token < in JSON at position 0') as SyntaxError & {
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
};
|
||||
err.status = 400;
|
||||
err.body = {};
|
||||
|
||||
handleJsonParseError(err, req as Request, res as Response, next);
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(400);
|
||||
expect(jsonSpy).toHaveBeenCalledWith({
|
||||
error: 'Invalid JSON format',
|
||||
message: 'The request body contains malformed JSON',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not reflect user input in error message', () => {
|
||||
const maliciousInput = '<script>alert("xss")</script>';
|
||||
const err = new SyntaxError(
|
||||
`Unexpected token < in JSON at position 0: ${maliciousInput}`,
|
||||
) as SyntaxError & {
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
};
|
||||
err.status = 400;
|
||||
err.body = maliciousInput;
|
||||
|
||||
handleJsonParseError(err, req as Request, res as Response, next);
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(400);
|
||||
const errorResponse = jsonSpy.mock.calls[0][0];
|
||||
expect(errorResponse.message).not.toContain(maliciousInput);
|
||||
expect(errorResponse.message).toBe('The request body contains malformed JSON');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle JSON parse error with HTML tags in body', () => {
|
||||
const err = new SyntaxError('Invalid JSON') as SyntaxError & {
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
};
|
||||
err.status = 400;
|
||||
err.body = '<html><body><h1>XSS</h1></body></html>';
|
||||
|
||||
handleJsonParseError(err, req as Request, res as Response, next);
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(400);
|
||||
const errorResponse = jsonSpy.mock.calls[0][0];
|
||||
expect(errorResponse.message).not.toContain('<html>');
|
||||
expect(errorResponse.message).not.toContain('<script>');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-JSON errors', () => {
|
||||
it('should pass through non-SyntaxError errors', () => {
|
||||
const err = new Error('Some other error');
|
||||
|
||||
handleJsonParseError(err, req as Request, res as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(err);
|
||||
expect(statusSpy).not.toHaveBeenCalled();
|
||||
expect(jsonSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass through SyntaxError without status 400', () => {
|
||||
const err = new SyntaxError('Some syntax error') as SyntaxError & { status?: number };
|
||||
err.status = 500;
|
||||
|
||||
handleJsonParseError(err, req as Request, res as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(err);
|
||||
expect(statusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass through SyntaxError without body property', () => {
|
||||
const err = new SyntaxError('Some syntax error') as SyntaxError & { status?: number };
|
||||
err.status = 400;
|
||||
|
||||
handleJsonParseError(err, req as Request, res as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(err);
|
||||
expect(statusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass through TypeError', () => {
|
||||
const err = new TypeError('Type error');
|
||||
|
||||
handleJsonParseError(err, req as Request, res as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(err);
|
||||
expect(statusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('security verification', () => {
|
||||
it('should return generic error message for all JSON parse errors', () => {
|
||||
const testCases = [
|
||||
'Unexpected token < in JSON',
|
||||
'Unexpected end of JSON input',
|
||||
'Invalid or unexpected token',
|
||||
'<script>alert(1)</script>',
|
||||
'"><img src=x onerror=alert(1)>',
|
||||
];
|
||||
|
||||
testCases.forEach((errorMsg) => {
|
||||
const err = new SyntaxError(errorMsg) as SyntaxError & {
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
};
|
||||
err.status = 400;
|
||||
err.body = errorMsg;
|
||||
|
||||
jsonSpy.mockClear();
|
||||
statusSpy.mockClear();
|
||||
(next as jest.Mock).mockClear();
|
||||
|
||||
handleJsonParseError(err, req as Request, res as Response, next);
|
||||
|
||||
const errorResponse = jsonSpy.mock.calls[0][0];
|
||||
// Verify the generic message is always returned, not the user input
|
||||
expect(errorResponse.message).toBe('The request body contains malformed JSON');
|
||||
expect(errorResponse.error).toBe('Invalid JSON format');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
40
packages/api/src/middleware/json.ts
Normal file
40
packages/api/src/middleware/json.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* Middleware to handle JSON parsing errors from express.json()
|
||||
* Prevents user input from being reflected in error messages (XSS prevention)
|
||||
*
|
||||
* This middleware should be placed immediately after express.json() middleware.
|
||||
*
|
||||
* @param err - Error object from express.json()
|
||||
* @param req - Express request object
|
||||
* @param res - Express response object
|
||||
* @param next - Express next function
|
||||
*
|
||||
* @example
|
||||
* app.use(express.json({ limit: '3mb' }));
|
||||
* app.use(handleJsonParseError);
|
||||
*/
|
||||
export function handleJsonParseError(
|
||||
err: Error & { status?: number; body?: unknown },
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
||||
logger.warn('[JSON Parse Error] Invalid JSON received', {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
res.status(400).json({
|
||||
error: 'Invalid JSON format',
|
||||
message: 'The request body contains malformed JSON',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next(err);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { SystemCategories } from 'librechat-data-provider';
|
||||
import type { IPromptGroupDocument as IPromptGroup } from '@librechat/data-schemas';
|
||||
import type { Types } from 'mongoose';
|
||||
import type { PromptGroupsListResponse } from '~/types';
|
||||
import { escapeRegExp } from '~/utils/common';
|
||||
|
||||
/**
|
||||
* Formats prompt groups for the paginated /groups endpoint response
|
||||
@@ -101,7 +102,6 @@ export function buildPromptGroupFilter({
|
||||
|
||||
// Handle name filter - convert to regex for case-insensitive search
|
||||
if (name) {
|
||||
const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
filter.name = new RegExp(escapeRegExp(name), 'i');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './format';
|
||||
export * from './migration';
|
||||
export * from './schemas';
|
||||
|
||||
222
packages/api/src/prompts/schemas.spec.ts
Normal file
222
packages/api/src/prompts/schemas.spec.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
updatePromptGroupSchema,
|
||||
validatePromptGroupUpdate,
|
||||
safeValidatePromptGroupUpdate,
|
||||
} from './schemas';
|
||||
|
||||
describe('updatePromptGroupSchema', () => {
|
||||
describe('allowed fields', () => {
|
||||
it('should accept valid name field', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({ name: 'Test Group' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Test Group');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept valid oneliner field', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({ oneliner: 'A short description' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.oneliner).toBe('A short description');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept valid category field', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({ category: 'testing' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.category).toBe('testing');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept valid projectIds array', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({
|
||||
projectIds: ['proj1', 'proj2'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.projectIds).toEqual(['proj1', 'proj2']);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept valid removeProjectIds array', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({
|
||||
removeProjectIds: ['proj1'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.removeProjectIds).toEqual(['proj1']);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept valid command field', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({ command: 'my-command-123' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.command).toBe('my-command-123');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept null command field', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({ command: null });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.command).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept multiple valid fields', () => {
|
||||
const input = {
|
||||
name: 'Updated Name',
|
||||
category: 'new-category',
|
||||
oneliner: 'New description',
|
||||
};
|
||||
const result = updatePromptGroupSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(input);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept empty object', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('security - strips sensitive fields', () => {
|
||||
it('should reject author field', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({
|
||||
name: 'Test',
|
||||
author: '507f1f77bcf86cd799439011',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject authorName field', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({
|
||||
name: 'Test',
|
||||
authorName: 'Malicious Author',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject _id field', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({
|
||||
name: 'Test',
|
||||
_id: '507f1f77bcf86cd799439011',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject productionId field', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({
|
||||
name: 'Test',
|
||||
productionId: '507f1f77bcf86cd799439011',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject createdAt field', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({
|
||||
name: 'Test',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject updatedAt field', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({
|
||||
name: 'Test',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject __v field', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({
|
||||
name: 'Test',
|
||||
__v: 999,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject multiple sensitive fields in a single request', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({
|
||||
name: 'Legit Name',
|
||||
author: '507f1f77bcf86cd799439011',
|
||||
authorName: 'Hacker',
|
||||
_id: 'newid123',
|
||||
productionId: 'prodid456',
|
||||
createdAt: '2020-01-01T00:00:00.000Z',
|
||||
__v: 999,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation rules', () => {
|
||||
it('should reject empty name', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({ name: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject name exceeding max length', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({ name: 'a'.repeat(256) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject oneliner exceeding max length', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({ oneliner: 'a'.repeat(501) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject category exceeding max length', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({ category: 'a'.repeat(101) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject command with invalid characters (uppercase)', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({ command: 'MyCommand' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject command with invalid characters (spaces)', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({ command: 'my command' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject command with invalid characters (special)', () => {
|
||||
const result = updatePromptGroupSchema.safeParse({ command: 'my_command!' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePromptGroupUpdate', () => {
|
||||
it('should return validated data for valid input', () => {
|
||||
const input = { name: 'Test', category: 'testing' };
|
||||
const result = validatePromptGroupUpdate(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should throw ZodError for invalid input', () => {
|
||||
expect(() => validatePromptGroupUpdate({ author: 'malicious-id' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeValidatePromptGroupUpdate', () => {
|
||||
it('should return success true for valid input', () => {
|
||||
const result = safeValidatePromptGroupUpdate({ name: 'Test' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should return success false for invalid input with errors', () => {
|
||||
const result = safeValidatePromptGroupUpdate({ author: 'malicious-id' });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
53
packages/api/src/prompts/schemas.ts
Normal file
53
packages/api/src/prompts/schemas.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { z } from 'zod';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* Schema for validating prompt group update payloads.
|
||||
* Only allows fields that users should be able to modify.
|
||||
* Sensitive fields like author, authorName, _id, productionId, etc. are excluded.
|
||||
*/
|
||||
export const updatePromptGroupSchema = z
|
||||
.object({
|
||||
/** The name of the prompt group */
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
/** Short description/oneliner for the prompt group */
|
||||
oneliner: z.string().max(500).optional(),
|
||||
/** Category for organizing prompt groups */
|
||||
category: z.string().max(100).optional(),
|
||||
/** Project IDs to add for sharing */
|
||||
projectIds: z.array(z.string()).optional(),
|
||||
/** Project IDs to remove from sharing */
|
||||
removeProjectIds: z.array(z.string()).optional(),
|
||||
/** Command shortcut for the prompt group */
|
||||
command: z
|
||||
.string()
|
||||
.max(Constants.COMMANDS_MAX_LENGTH as number)
|
||||
.regex(/^[a-z0-9-]*$/, {
|
||||
message: 'Command must only contain lowercase alphanumeric characters and hyphens',
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type TUpdatePromptGroupSchema = z.infer<typeof updatePromptGroupSchema>;
|
||||
|
||||
/**
|
||||
* Validates and sanitizes a prompt group update payload.
|
||||
* Returns only the allowed fields, stripping any sensitive fields.
|
||||
* @param data - The raw request body to validate
|
||||
* @returns The validated and sanitized payload
|
||||
* @throws ZodError if validation fails
|
||||
*/
|
||||
export function validatePromptGroupUpdate(data: unknown): TUpdatePromptGroupSchema {
|
||||
return updatePromptGroupSchema.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely validates a prompt group update payload without throwing.
|
||||
* @param data - The raw request body to validate
|
||||
* @returns A SafeParseResult with either the validated data or validation errors
|
||||
*/
|
||||
export function safeValidatePromptGroupUpdate(data: unknown) {
|
||||
return updatePromptGroupSchema.safeParse(data);
|
||||
}
|
||||
@@ -48,3 +48,12 @@ export function optionalChainWithEmptyCheck(
|
||||
}
|
||||
return values[values.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes special characters in a string for use in a regular expression.
|
||||
* @param str - The string to escape.
|
||||
* @returns The escaped string safe for use in RegExp.
|
||||
*/
|
||||
export function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ export * from './promise';
|
||||
export * from './sanitizeTitle';
|
||||
export * from './tempChatRetention';
|
||||
export * from './text';
|
||||
export { default as Tokenizer } from './tokenizer';
|
||||
export { default as Tokenizer, countTokens } from './tokenizer';
|
||||
export * from './yaml';
|
||||
export * from './http';
|
||||
export * from './tokens';
|
||||
export * from './message';
|
||||
|
||||
122
packages/api/src/utils/message.spec.ts
Normal file
122
packages/api/src/utils/message.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { sanitizeFileForTransmit, sanitizeMessageForTransmit } from './message';
|
||||
|
||||
describe('sanitizeFileForTransmit', () => {
|
||||
it('should remove text field from file', () => {
|
||||
const file = {
|
||||
file_id: 'test-123',
|
||||
filename: 'test.txt',
|
||||
text: 'This is a very long text content that should be stripped',
|
||||
bytes: 1000,
|
||||
};
|
||||
|
||||
const result = sanitizeFileForTransmit(file);
|
||||
|
||||
expect(result.file_id).toBe('test-123');
|
||||
expect(result.filename).toBe('test.txt');
|
||||
expect(result.bytes).toBe(1000);
|
||||
expect(result).not.toHaveProperty('text');
|
||||
});
|
||||
|
||||
it('should remove _id and __v fields', () => {
|
||||
const file = {
|
||||
file_id: 'test-123',
|
||||
_id: 'mongo-id',
|
||||
__v: 0,
|
||||
filename: 'test.txt',
|
||||
};
|
||||
|
||||
const result = sanitizeFileForTransmit(file);
|
||||
|
||||
expect(result.file_id).toBe('test-123');
|
||||
expect(result).not.toHaveProperty('_id');
|
||||
expect(result).not.toHaveProperty('__v');
|
||||
});
|
||||
|
||||
it('should not modify original file object', () => {
|
||||
const file = {
|
||||
file_id: 'test-123',
|
||||
text: 'original text',
|
||||
};
|
||||
|
||||
sanitizeFileForTransmit(file);
|
||||
|
||||
expect(file.text).toBe('original text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeMessageForTransmit', () => {
|
||||
it('should remove fileContext from message', () => {
|
||||
const message = {
|
||||
messageId: 'msg-123',
|
||||
text: 'Hello world',
|
||||
fileContext: 'This is a very long context that should be stripped',
|
||||
};
|
||||
|
||||
const result = sanitizeMessageForTransmit(message);
|
||||
|
||||
expect(result.messageId).toBe('msg-123');
|
||||
expect(result.text).toBe('Hello world');
|
||||
expect(result).not.toHaveProperty('fileContext');
|
||||
});
|
||||
|
||||
it('should sanitize files array', () => {
|
||||
const message = {
|
||||
messageId: 'msg-123',
|
||||
files: [
|
||||
{ file_id: 'file-1', text: 'long text 1', filename: 'a.txt' },
|
||||
{ file_id: 'file-2', text: 'long text 2', filename: 'b.txt' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = sanitizeMessageForTransmit(message);
|
||||
|
||||
expect(result.files).toHaveLength(2);
|
||||
expect(result.files?.[0].file_id).toBe('file-1');
|
||||
expect(result.files?.[0].filename).toBe('a.txt');
|
||||
expect(result.files?.[0]).not.toHaveProperty('text');
|
||||
expect(result.files?.[1]).not.toHaveProperty('text');
|
||||
});
|
||||
|
||||
it('should handle null/undefined message', () => {
|
||||
expect(sanitizeMessageForTransmit(null as unknown as object)).toBeNull();
|
||||
expect(sanitizeMessageForTransmit(undefined as unknown as object)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle message without files', () => {
|
||||
const message = {
|
||||
messageId: 'msg-123',
|
||||
text: 'Hello',
|
||||
};
|
||||
|
||||
const result = sanitizeMessageForTransmit(message);
|
||||
|
||||
expect(result.messageId).toBe('msg-123');
|
||||
expect(result.text).toBe('Hello');
|
||||
});
|
||||
|
||||
it('should create new array reference for empty files array (immutability)', () => {
|
||||
const message = {
|
||||
messageId: 'msg-123',
|
||||
files: [] as { file_id: string }[],
|
||||
};
|
||||
|
||||
const result = sanitizeMessageForTransmit(message);
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
// New array reference ensures full immutability even for empty arrays
|
||||
expect(result.files).not.toBe(message.files);
|
||||
});
|
||||
|
||||
it('should not modify original message object', () => {
|
||||
const message = {
|
||||
messageId: 'msg-123',
|
||||
fileContext: 'original context',
|
||||
files: [{ file_id: 'file-1', text: 'original text' }],
|
||||
};
|
||||
|
||||
sanitizeMessageForTransmit(message);
|
||||
|
||||
expect(message.fileContext).toBe('original context');
|
||||
expect(message.files[0].text).toBe('original text');
|
||||
});
|
||||
});
|
||||
68
packages/api/src/utils/message.ts
Normal file
68
packages/api/src/utils/message.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { TFile, TMessage } from 'librechat-data-provider';
|
||||
|
||||
/** Fields to strip from files before client transmission */
|
||||
const FILE_STRIP_FIELDS = ['text', '_id', '__v'] as const;
|
||||
|
||||
/** Fields to strip from messages before client transmission */
|
||||
const MESSAGE_STRIP_FIELDS = ['fileContext'] as const;
|
||||
|
||||
/**
|
||||
* Strips large/unnecessary fields from a file object before transmitting to client.
|
||||
* Use this within existing loops when building file arrays to avoid extra iterations.
|
||||
*
|
||||
* @param file - The file object to sanitize
|
||||
* @returns A new file object without the stripped fields
|
||||
*
|
||||
* @example
|
||||
* // Use in existing file processing loop:
|
||||
* for (const attachment of client.options.attachments) {
|
||||
* if (messageFiles.has(attachment.file_id)) {
|
||||
* userMessage.files.push(sanitizeFileForTransmit(attachment));
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export function sanitizeFileForTransmit<T extends Partial<TFile>>(
|
||||
file: T,
|
||||
): Omit<T, (typeof FILE_STRIP_FIELDS)[number]> {
|
||||
const sanitized = { ...file };
|
||||
for (const field of FILE_STRIP_FIELDS) {
|
||||
delete sanitized[field as keyof typeof sanitized];
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a message object before transmitting to client.
|
||||
* Removes large fields like `fileContext` and strips `text` from embedded files.
|
||||
*
|
||||
* @param message - The message object to sanitize
|
||||
* @returns A new message object safe for client transmission
|
||||
*
|
||||
* @example
|
||||
* sendEvent(res, {
|
||||
* final: true,
|
||||
* requestMessage: sanitizeMessageForTransmit(userMessage),
|
||||
* responseMessage: response,
|
||||
* });
|
||||
*/
|
||||
export function sanitizeMessageForTransmit<T extends Partial<TMessage>>(
|
||||
message: T,
|
||||
): Omit<T, (typeof MESSAGE_STRIP_FIELDS)[number]> {
|
||||
if (!message) {
|
||||
return message as Omit<T, (typeof MESSAGE_STRIP_FIELDS)[number]>;
|
||||
}
|
||||
|
||||
const sanitized = { ...message };
|
||||
|
||||
// Remove message-level fields
|
||||
for (const field of MESSAGE_STRIP_FIELDS) {
|
||||
delete sanitized[field as keyof typeof sanitized];
|
||||
}
|
||||
|
||||
// Always create a new array when files exist to maintain full immutability
|
||||
if (Array.isArray(sanitized.files)) {
|
||||
sanitized.files = sanitized.files.map((file) => sanitizeFileForTransmit(file));
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
@@ -36,30 +36,16 @@ const OPENID_TOKEN_FIELDS = [
|
||||
|
||||
export function extractOpenIDTokenInfo(user: IUser | null | undefined): OpenIDTokenInfo | null {
|
||||
if (!user) {
|
||||
logger.debug('[extractOpenIDTokenInfo] No user provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(
|
||||
'[extractOpenIDTokenInfo] User provider:',
|
||||
user.provider,
|
||||
'openidId:',
|
||||
user.openidId,
|
||||
);
|
||||
|
||||
if (user.provider !== 'openid' && !user.openidId) {
|
||||
logger.debug('[extractOpenIDTokenInfo] User not authenticated via OpenID');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenInfo: OpenIDTokenInfo = {};
|
||||
|
||||
logger.debug(
|
||||
'[extractOpenIDTokenInfo] Checking for federatedTokens in user object:',
|
||||
'federatedTokens' in user,
|
||||
);
|
||||
|
||||
if ('federatedTokens' in user && isFederatedTokens(user.federatedTokens)) {
|
||||
const tokens = user.federatedTokens;
|
||||
logger.debug('[extractOpenIDTokenInfo] Found federatedTokens:', {
|
||||
|
||||
851
packages/api/src/utils/text.spec.ts
Normal file
851
packages/api/src/utils/text.spec.ts
Normal file
@@ -0,0 +1,851 @@
|
||||
import { processTextWithTokenLimit, TokenCountFn } from './text';
|
||||
import Tokenizer, { countTokens } from './tokenizer';
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* OLD IMPLEMENTATION (Binary Search) - kept for comparison testing
|
||||
* This is the original algorithm that caused CPU spikes
|
||||
*/
|
||||
async function processTextWithTokenLimitOLD({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
}: {
|
||||
text: string;
|
||||
tokenLimit: number;
|
||||
tokenCountFn: TokenCountFn;
|
||||
}): Promise<{ text: string; tokenCount: number; wasTruncated: boolean }> {
|
||||
const originalTokenCount = await tokenCountFn(text);
|
||||
|
||||
if (originalTokenCount <= tokenLimit) {
|
||||
return {
|
||||
text,
|
||||
tokenCount: originalTokenCount,
|
||||
wasTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
let low = 0;
|
||||
let high = text.length;
|
||||
let bestText = '';
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const truncatedText = text.substring(0, mid);
|
||||
const tokenCount = await tokenCountFn(truncatedText);
|
||||
|
||||
if (tokenCount <= tokenLimit) {
|
||||
bestText = truncatedText;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const finalTokenCount = await tokenCountFn(bestText);
|
||||
|
||||
return {
|
||||
text: bestText,
|
||||
tokenCount: finalTokenCount,
|
||||
wasTruncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a wrapper around Tokenizer.getTokenCount that tracks call count
|
||||
*/
|
||||
const createRealTokenCounter = () => {
|
||||
let callCount = 0;
|
||||
const tokenCountFn = (text: string): number => {
|
||||
callCount++;
|
||||
return Tokenizer.getTokenCount(text, 'cl100k_base');
|
||||
};
|
||||
return {
|
||||
tokenCountFn,
|
||||
getCallCount: () => callCount,
|
||||
resetCallCount: () => {
|
||||
callCount = 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a wrapper around the async countTokens function that tracks call count
|
||||
*/
|
||||
const createCountTokensCounter = () => {
|
||||
let callCount = 0;
|
||||
const tokenCountFn = async (text: string): Promise<number> => {
|
||||
callCount++;
|
||||
return countTokens(text);
|
||||
};
|
||||
return {
|
||||
tokenCountFn,
|
||||
getCallCount: () => callCount,
|
||||
resetCallCount: () => {
|
||||
callCount = 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('processTextWithTokenLimit', () => {
|
||||
/**
|
||||
* Creates a mock token count function that simulates realistic token counting.
|
||||
* Roughly 4 characters per token (common for English text).
|
||||
* Tracks call count to verify efficiency.
|
||||
*/
|
||||
const createMockTokenCounter = () => {
|
||||
let callCount = 0;
|
||||
const tokenCountFn = (text: string): number => {
|
||||
callCount++;
|
||||
return Math.ceil(text.length / 4);
|
||||
};
|
||||
return {
|
||||
tokenCountFn,
|
||||
getCallCount: () => callCount,
|
||||
resetCallCount: () => {
|
||||
callCount = 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Creates a string of specified character length */
|
||||
const createTextOfLength = (charLength: number): string => {
|
||||
return 'a'.repeat(charLength);
|
||||
};
|
||||
|
||||
/** Creates realistic text content with varied token density */
|
||||
const createRealisticText = (approximateTokens: number): string => {
|
||||
const words = [
|
||||
'the',
|
||||
'quick',
|
||||
'brown',
|
||||
'fox',
|
||||
'jumps',
|
||||
'over',
|
||||
'lazy',
|
||||
'dog',
|
||||
'lorem',
|
||||
'ipsum',
|
||||
'dolor',
|
||||
'sit',
|
||||
'amet',
|
||||
'consectetur',
|
||||
'adipiscing',
|
||||
'elit',
|
||||
'sed',
|
||||
'do',
|
||||
'eiusmod',
|
||||
'tempor',
|
||||
'incididunt',
|
||||
'ut',
|
||||
'labore',
|
||||
'et',
|
||||
'dolore',
|
||||
'magna',
|
||||
'aliqua',
|
||||
'enim',
|
||||
'ad',
|
||||
'minim',
|
||||
'veniam',
|
||||
'authentication',
|
||||
'implementation',
|
||||
'configuration',
|
||||
'documentation',
|
||||
];
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < approximateTokens; i++) {
|
||||
result.push(words[i % words.length]);
|
||||
}
|
||||
return result.join(' ');
|
||||
};
|
||||
|
||||
describe('tokenCountFn flexibility (sync and async)', () => {
|
||||
it('should work with synchronous tokenCountFn', async () => {
|
||||
const syncTokenCountFn = (text: string): number => Math.ceil(text.length / 4);
|
||||
const text = 'Hello, world! This is a test message.';
|
||||
const tokenLimit = 5;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: syncTokenCountFn,
|
||||
});
|
||||
|
||||
expect(result.wasTruncated).toBe(true);
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
});
|
||||
|
||||
it('should work with asynchronous tokenCountFn', async () => {
|
||||
const asyncTokenCountFn = async (text: string): Promise<number> => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
return Math.ceil(text.length / 4);
|
||||
};
|
||||
const text = 'Hello, world! This is a test message.';
|
||||
const tokenLimit = 5;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: asyncTokenCountFn,
|
||||
});
|
||||
|
||||
expect(result.wasTruncated).toBe(true);
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
});
|
||||
|
||||
it('should produce equivalent results with sync and async tokenCountFn', async () => {
|
||||
const syncTokenCountFn = (text: string): number => Math.ceil(text.length / 4);
|
||||
const asyncTokenCountFn = async (text: string): Promise<number> => Math.ceil(text.length / 4);
|
||||
const text = 'a'.repeat(8000);
|
||||
const tokenLimit = 1000;
|
||||
|
||||
const syncResult = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: syncTokenCountFn,
|
||||
});
|
||||
|
||||
const asyncResult = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: asyncTokenCountFn,
|
||||
});
|
||||
|
||||
expect(syncResult.tokenCount).toBe(asyncResult.tokenCount);
|
||||
expect(syncResult.wasTruncated).toBe(asyncResult.wasTruncated);
|
||||
expect(syncResult.text.length).toBe(asyncResult.text.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when text is under the token limit', () => {
|
||||
it('should return original text unchanged', async () => {
|
||||
const { tokenCountFn } = createMockTokenCounter();
|
||||
const text = 'Hello, world!';
|
||||
const tokenLimit = 100;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(result.text).toBe(text);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('should return correct token count', async () => {
|
||||
const { tokenCountFn } = createMockTokenCounter();
|
||||
const text = 'Hello, world!';
|
||||
const tokenLimit = 100;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(result.tokenCount).toBe(Math.ceil(text.length / 4));
|
||||
});
|
||||
|
||||
it('should only call tokenCountFn once when under limit', async () => {
|
||||
const { tokenCountFn, getCallCount } = createMockTokenCounter();
|
||||
const text = 'Hello, world!';
|
||||
const tokenLimit = 100;
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(getCallCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when text is exactly at the token limit', () => {
|
||||
it('should return original text unchanged', async () => {
|
||||
const { tokenCountFn } = createMockTokenCounter();
|
||||
const text = createTextOfLength(400);
|
||||
const tokenLimit = 100;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(result.text).toBe(text);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
expect(result.tokenCount).toBe(tokenLimit);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when text exceeds the token limit', () => {
|
||||
it('should truncate text to fit within limit', async () => {
|
||||
const { tokenCountFn } = createMockTokenCounter();
|
||||
const text = createTextOfLength(8000);
|
||||
const tokenLimit = 1000;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(result.wasTruncated).toBe(true);
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
expect(result.text.length).toBeLessThan(text.length);
|
||||
});
|
||||
|
||||
it('should truncate text to be close to but not exceed the limit', async () => {
|
||||
const { tokenCountFn } = createMockTokenCounter();
|
||||
const text = createTextOfLength(8000);
|
||||
const tokenLimit = 1000;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
expect(result.tokenCount).toBeGreaterThan(tokenLimit * 0.9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('efficiency - tokenCountFn call count', () => {
|
||||
it('should call tokenCountFn at most 7 times for large text (vs ~17 for binary search)', async () => {
|
||||
const { tokenCountFn, getCallCount } = createMockTokenCounter();
|
||||
const text = createTextOfLength(400000);
|
||||
const tokenLimit = 50000;
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(getCallCount()).toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
it('should typically call tokenCountFn only 2-3 times for standard truncation', async () => {
|
||||
const { tokenCountFn, getCallCount } = createMockTokenCounter();
|
||||
const text = createTextOfLength(40000);
|
||||
const tokenLimit = 5000;
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(getCallCount()).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should call tokenCountFn only once when text is under limit', async () => {
|
||||
const { tokenCountFn, getCallCount } = createMockTokenCounter();
|
||||
const text = createTextOfLength(1000);
|
||||
const tokenLimit = 10000;
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(getCallCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle very large text (100k+ tokens) efficiently', async () => {
|
||||
const { tokenCountFn, getCallCount } = createMockTokenCounter();
|
||||
const text = createTextOfLength(500000);
|
||||
const tokenLimit = 100000;
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(getCallCount()).toBeLessThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty text', async () => {
|
||||
const { tokenCountFn } = createMockTokenCounter();
|
||||
const text = '';
|
||||
const tokenLimit = 100;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('');
|
||||
expect(result.tokenCount).toBe(0);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle token limit of 1', async () => {
|
||||
const { tokenCountFn } = createMockTokenCounter();
|
||||
const text = createTextOfLength(1000);
|
||||
const tokenLimit = 1;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(result.wasTruncated).toBe(true);
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
});
|
||||
|
||||
it('should handle text that is just slightly over the limit', async () => {
|
||||
const { tokenCountFn } = createMockTokenCounter();
|
||||
const text = createTextOfLength(404);
|
||||
const tokenLimit = 100;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
});
|
||||
|
||||
expect(result.wasTruncated).toBe(true);
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
});
|
||||
});
|
||||
|
||||
describe('correctness with variable token density', () => {
|
||||
it('should handle text with varying token density', async () => {
|
||||
const variableDensityTokenCounter = (text: string): number => {
|
||||
const shortWords = (text.match(/\s+/g) || []).length;
|
||||
return Math.ceil(text.length / 4) + shortWords;
|
||||
};
|
||||
|
||||
const text = 'This is a test with many short words and some longer concatenated words too';
|
||||
const tokenLimit = 10;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: variableDensityTokenCounter,
|
||||
});
|
||||
|
||||
expect(result.wasTruncated).toBe(true);
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
});
|
||||
});
|
||||
|
||||
describe('direct comparison with OLD binary search implementation', () => {
|
||||
it('should produce equivalent results to the old implementation', async () => {
|
||||
const oldCounter = createMockTokenCounter();
|
||||
const newCounter = createMockTokenCounter();
|
||||
const text = createTextOfLength(8000);
|
||||
const tokenLimit = 1000;
|
||||
|
||||
const oldResult = await processTextWithTokenLimitOLD({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: oldCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
const newResult = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: newCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
expect(newResult.wasTruncated).toBe(oldResult.wasTruncated);
|
||||
expect(newResult.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
expect(oldResult.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
});
|
||||
|
||||
it('should use significantly fewer tokenCountFn calls than old implementation (400k chars)', async () => {
|
||||
const oldCounter = createMockTokenCounter();
|
||||
const newCounter = createMockTokenCounter();
|
||||
const text = createTextOfLength(400000);
|
||||
const tokenLimit = 50000;
|
||||
|
||||
await processTextWithTokenLimitOLD({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: oldCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: newCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
const oldCalls = oldCounter.getCallCount();
|
||||
const newCalls = newCounter.getCallCount();
|
||||
|
||||
console.log(
|
||||
`[400k chars] OLD implementation: ${oldCalls} calls, NEW implementation: ${newCalls} calls`,
|
||||
);
|
||||
console.log(`[400k chars] Reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
|
||||
|
||||
expect(newCalls).toBeLessThan(oldCalls);
|
||||
expect(newCalls).toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
it('should use significantly fewer tokenCountFn calls than old implementation (500k chars, 100k token limit)', async () => {
|
||||
const oldCounter = createMockTokenCounter();
|
||||
const newCounter = createMockTokenCounter();
|
||||
const text = createTextOfLength(500000);
|
||||
const tokenLimit = 100000;
|
||||
|
||||
await processTextWithTokenLimitOLD({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: oldCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: newCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
const oldCalls = oldCounter.getCallCount();
|
||||
const newCalls = newCounter.getCallCount();
|
||||
|
||||
console.log(
|
||||
`[500k chars] OLD implementation: ${oldCalls} calls, NEW implementation: ${newCalls} calls`,
|
||||
);
|
||||
console.log(`[500k chars] Reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
|
||||
|
||||
expect(newCalls).toBeLessThan(oldCalls);
|
||||
});
|
||||
|
||||
it('should achieve at least 70% reduction in tokenCountFn calls', async () => {
|
||||
const oldCounter = createMockTokenCounter();
|
||||
const newCounter = createMockTokenCounter();
|
||||
const text = createTextOfLength(500000);
|
||||
const tokenLimit = 100000;
|
||||
|
||||
await processTextWithTokenLimitOLD({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: oldCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: newCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
const oldCalls = oldCounter.getCallCount();
|
||||
const newCalls = newCounter.getCallCount();
|
||||
const reduction = 1 - newCalls / oldCalls;
|
||||
|
||||
console.log(
|
||||
`Efficiency improvement: ${(reduction * 100).toFixed(1)}% fewer tokenCountFn calls`,
|
||||
);
|
||||
|
||||
expect(reduction).toBeGreaterThanOrEqual(0.7);
|
||||
});
|
||||
|
||||
it('should simulate the reported scenario (122k tokens, 100k limit)', async () => {
|
||||
const oldCounter = createMockTokenCounter();
|
||||
const newCounter = createMockTokenCounter();
|
||||
const text = createTextOfLength(489564);
|
||||
const tokenLimit = 100000;
|
||||
|
||||
await processTextWithTokenLimitOLD({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: oldCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: newCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
const oldCalls = oldCounter.getCallCount();
|
||||
const newCalls = newCounter.getCallCount();
|
||||
|
||||
console.log(`[User reported scenario: ~122k tokens]`);
|
||||
console.log(`OLD implementation: ${oldCalls} tokenCountFn calls`);
|
||||
console.log(`NEW implementation: ${newCalls} tokenCountFn calls`);
|
||||
console.log(`Reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
|
||||
|
||||
expect(newCalls).toBeLessThan(oldCalls);
|
||||
expect(newCalls).toBeLessThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('direct comparison with REAL tiktoken tokenizer', () => {
|
||||
beforeEach(() => {
|
||||
Tokenizer.freeAndResetAllEncoders();
|
||||
});
|
||||
|
||||
it('should produce valid truncation with real tokenizer', async () => {
|
||||
const counter = createRealTokenCounter();
|
||||
const text = createRealisticText(5000);
|
||||
const tokenLimit = 1000;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: counter.tokenCountFn,
|
||||
});
|
||||
|
||||
expect(result.wasTruncated).toBe(true);
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
expect(result.text.length).toBeLessThan(text.length);
|
||||
});
|
||||
|
||||
it('should use fewer tiktoken calls than old implementation (realistic text)', async () => {
|
||||
const oldCounter = createRealTokenCounter();
|
||||
const newCounter = createRealTokenCounter();
|
||||
const text = createRealisticText(15000);
|
||||
const tokenLimit = 5000;
|
||||
|
||||
await processTextWithTokenLimitOLD({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: oldCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
Tokenizer.freeAndResetAllEncoders();
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: newCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
const oldCalls = oldCounter.getCallCount();
|
||||
const newCalls = newCounter.getCallCount();
|
||||
|
||||
console.log(`[Real tiktoken ~15k tokens] OLD: ${oldCalls} calls, NEW: ${newCalls} calls`);
|
||||
console.log(`[Real tiktoken] Reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
|
||||
|
||||
expect(newCalls).toBeLessThan(oldCalls);
|
||||
});
|
||||
|
||||
it('should handle the reported user scenario with real tokenizer (~120k tokens)', async () => {
|
||||
const oldCounter = createRealTokenCounter();
|
||||
const newCounter = createRealTokenCounter();
|
||||
const text = createRealisticText(120000);
|
||||
const tokenLimit = 100000;
|
||||
|
||||
const startOld = performance.now();
|
||||
await processTextWithTokenLimitOLD({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: oldCounter.tokenCountFn,
|
||||
});
|
||||
const timeOld = performance.now() - startOld;
|
||||
|
||||
Tokenizer.freeAndResetAllEncoders();
|
||||
|
||||
const startNew = performance.now();
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: newCounter.tokenCountFn,
|
||||
});
|
||||
const timeNew = performance.now() - startNew;
|
||||
|
||||
const oldCalls = oldCounter.getCallCount();
|
||||
const newCalls = newCounter.getCallCount();
|
||||
|
||||
console.log(`\n[REAL TIKTOKEN - User reported scenario: ~120k tokens]`);
|
||||
console.log(`OLD implementation: ${oldCalls} tiktoken calls, ${timeOld.toFixed(0)}ms`);
|
||||
console.log(`NEW implementation: ${newCalls} tiktoken calls, ${timeNew.toFixed(0)}ms`);
|
||||
console.log(`Call reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
|
||||
console.log(`Time reduction: ${((1 - timeNew / timeOld) * 100).toFixed(1)}%`);
|
||||
console.log(
|
||||
`Result: truncated=${result.wasTruncated}, tokens=${result.tokenCount}/${tokenLimit}\n`,
|
||||
);
|
||||
|
||||
expect(newCalls).toBeLessThan(oldCalls);
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
expect(newCalls).toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
it('should achieve at least 70% reduction with real tokenizer', async () => {
|
||||
const oldCounter = createRealTokenCounter();
|
||||
const newCounter = createRealTokenCounter();
|
||||
const text = createRealisticText(50000);
|
||||
const tokenLimit = 10000;
|
||||
|
||||
await processTextWithTokenLimitOLD({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: oldCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
Tokenizer.freeAndResetAllEncoders();
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: newCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
const oldCalls = oldCounter.getCallCount();
|
||||
const newCalls = newCounter.getCallCount();
|
||||
const reduction = 1 - newCalls / oldCalls;
|
||||
|
||||
console.log(
|
||||
`[Real tiktoken 50k tokens] OLD: ${oldCalls}, NEW: ${newCalls}, Reduction: ${(reduction * 100).toFixed(1)}%`,
|
||||
);
|
||||
|
||||
expect(reduction).toBeGreaterThanOrEqual(0.7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('using countTokens async function from @librechat/api', () => {
|
||||
beforeEach(() => {
|
||||
Tokenizer.freeAndResetAllEncoders();
|
||||
});
|
||||
|
||||
it('countTokens should return correct token count', async () => {
|
||||
const text = 'Hello, world!';
|
||||
const count = await countTokens(text);
|
||||
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(typeof count).toBe('number');
|
||||
});
|
||||
|
||||
it('countTokens should handle empty string', async () => {
|
||||
const count = await countTokens('');
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should work with processTextWithTokenLimit using countTokens', async () => {
|
||||
const counter = createCountTokensCounter();
|
||||
const text = createRealisticText(5000);
|
||||
const tokenLimit = 1000;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: counter.tokenCountFn,
|
||||
});
|
||||
|
||||
expect(result.wasTruncated).toBe(true);
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
expect(result.text.length).toBeLessThan(text.length);
|
||||
});
|
||||
|
||||
it('should use fewer countTokens calls than old implementation', async () => {
|
||||
const oldCounter = createCountTokensCounter();
|
||||
const newCounter = createCountTokensCounter();
|
||||
const text = createRealisticText(15000);
|
||||
const tokenLimit = 5000;
|
||||
|
||||
await processTextWithTokenLimitOLD({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: oldCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
Tokenizer.freeAndResetAllEncoders();
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: newCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
const oldCalls = oldCounter.getCallCount();
|
||||
const newCalls = newCounter.getCallCount();
|
||||
|
||||
console.log(`[countTokens ~15k tokens] OLD: ${oldCalls} calls, NEW: ${newCalls} calls`);
|
||||
console.log(`[countTokens] Reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
|
||||
|
||||
expect(newCalls).toBeLessThan(oldCalls);
|
||||
});
|
||||
|
||||
it('should handle user reported scenario with countTokens (~120k tokens)', async () => {
|
||||
const oldCounter = createCountTokensCounter();
|
||||
const newCounter = createCountTokensCounter();
|
||||
const text = createRealisticText(120000);
|
||||
const tokenLimit = 100000;
|
||||
|
||||
const startOld = performance.now();
|
||||
await processTextWithTokenLimitOLD({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: oldCounter.tokenCountFn,
|
||||
});
|
||||
const timeOld = performance.now() - startOld;
|
||||
|
||||
Tokenizer.freeAndResetAllEncoders();
|
||||
|
||||
const startNew = performance.now();
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: newCounter.tokenCountFn,
|
||||
});
|
||||
const timeNew = performance.now() - startNew;
|
||||
|
||||
const oldCalls = oldCounter.getCallCount();
|
||||
const newCalls = newCounter.getCallCount();
|
||||
|
||||
console.log(`\n[countTokens - User reported scenario: ~120k tokens]`);
|
||||
console.log(`OLD implementation: ${oldCalls} countTokens calls, ${timeOld.toFixed(0)}ms`);
|
||||
console.log(`NEW implementation: ${newCalls} countTokens calls, ${timeNew.toFixed(0)}ms`);
|
||||
console.log(`Call reduction: ${((1 - newCalls / oldCalls) * 100).toFixed(1)}%`);
|
||||
console.log(`Time reduction: ${((1 - timeNew / timeOld) * 100).toFixed(1)}%`);
|
||||
console.log(
|
||||
`Result: truncated=${result.wasTruncated}, tokens=${result.tokenCount}/${tokenLimit}\n`,
|
||||
);
|
||||
|
||||
expect(newCalls).toBeLessThan(oldCalls);
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
expect(newCalls).toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
it('should achieve at least 70% reduction with countTokens', async () => {
|
||||
const oldCounter = createCountTokensCounter();
|
||||
const newCounter = createCountTokensCounter();
|
||||
const text = createRealisticText(50000);
|
||||
const tokenLimit = 10000;
|
||||
|
||||
await processTextWithTokenLimitOLD({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: oldCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
Tokenizer.freeAndResetAllEncoders();
|
||||
|
||||
await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn: newCounter.tokenCountFn,
|
||||
});
|
||||
|
||||
const oldCalls = oldCounter.getCallCount();
|
||||
const newCalls = newCounter.getCallCount();
|
||||
const reduction = 1 - newCalls / oldCalls;
|
||||
|
||||
console.log(
|
||||
`[countTokens 50k tokens] OLD: ${oldCalls}, NEW: ${newCalls}, Reduction: ${(reduction * 100).toFixed(1)}%`,
|
||||
);
|
||||
|
||||
expect(reduction).toBeGreaterThanOrEqual(0.7);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,39 @@
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
/** Token count function that can be sync or async */
|
||||
export type TokenCountFn = (text: string) => number | Promise<number>;
|
||||
|
||||
/**
|
||||
* Safety buffer multiplier applied to character position estimates during truncation.
|
||||
*
|
||||
* We use 98% (0.98) rather than 100% to intentionally undershoot the target on the first attempt.
|
||||
* This is necessary because:
|
||||
* - Token density varies across text (some regions may have more tokens per character than the average)
|
||||
* - The ratio-based estimate assumes uniform token distribution, which is rarely true
|
||||
* - Undershooting is safer than overshooting: exceeding the limit requires another iteration,
|
||||
* while being slightly under is acceptable
|
||||
* - In practice, this buffer reduces refinement iterations from 2-3 down to 0-1 in most cases
|
||||
*
|
||||
* @example
|
||||
* // If text has 1000 chars and 250 tokens (4 chars/token average), targeting 100 tokens:
|
||||
* // Without buffer: estimate = 1000 * (100/250) = 400 chars → might yield 105 tokens (over!)
|
||||
* // With 0.98 buffer: estimate = 400 * 0.98 = 392 chars → likely yields 97-99 tokens (safe)
|
||||
*/
|
||||
const TRUNCATION_SAFETY_BUFFER = 0.98;
|
||||
|
||||
/**
|
||||
* Processes text content by counting tokens and truncating if it exceeds the specified limit.
|
||||
* Uses ratio-based estimation to minimize expensive tokenCountFn calls.
|
||||
*
|
||||
* @param text - The text content to process
|
||||
* @param tokenLimit - The maximum number of tokens allowed
|
||||
* @param tokenCountFn - Function to count tokens
|
||||
* @param tokenCountFn - Function to count tokens (can be sync or async)
|
||||
* @returns Promise resolving to object with processed text, token count, and truncation status
|
||||
*
|
||||
* @remarks
|
||||
* This function uses a ratio-based estimation algorithm instead of binary search.
|
||||
* Binary search would require O(log n) tokenCountFn calls (~17 for 100k chars),
|
||||
* while this approach typically requires only 2-3 calls for a 90%+ reduction in CPU usage.
|
||||
*/
|
||||
export async function processTextWithTokenLimit({
|
||||
text,
|
||||
@@ -14,7 +42,7 @@ export async function processTextWithTokenLimit({
|
||||
}: {
|
||||
text: string;
|
||||
tokenLimit: number;
|
||||
tokenCountFn: (text: string) => number;
|
||||
tokenCountFn: TokenCountFn;
|
||||
}): Promise<{ text: string; tokenCount: number; wasTruncated: boolean }> {
|
||||
const originalTokenCount = await tokenCountFn(text);
|
||||
|
||||
@@ -26,40 +54,34 @@ export async function processTextWithTokenLimit({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Doing binary search here to find the truncation point efficiently
|
||||
* (May be a better way to go about this)
|
||||
*/
|
||||
let low = 0;
|
||||
let high = text.length;
|
||||
let bestText = '';
|
||||
|
||||
logger.debug(
|
||||
`[textTokenLimiter] Text content exceeds token limit: ${originalTokenCount} > ${tokenLimit}, truncating...`,
|
||||
);
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const truncatedText = text.substring(0, mid);
|
||||
const tokenCount = await tokenCountFn(truncatedText);
|
||||
const ratio = tokenLimit / originalTokenCount;
|
||||
let charPosition = Math.floor(text.length * ratio * TRUNCATION_SAFETY_BUFFER);
|
||||
|
||||
if (tokenCount <= tokenLimit) {
|
||||
bestText = truncatedText;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
let truncatedText = text.substring(0, charPosition);
|
||||
let tokenCount = await tokenCountFn(truncatedText);
|
||||
|
||||
const maxIterations = 5;
|
||||
let iterations = 0;
|
||||
|
||||
while (tokenCount > tokenLimit && iterations < maxIterations && charPosition > 0) {
|
||||
const overageRatio = tokenLimit / tokenCount;
|
||||
charPosition = Math.floor(charPosition * overageRatio * TRUNCATION_SAFETY_BUFFER);
|
||||
truncatedText = text.substring(0, charPosition);
|
||||
tokenCount = await tokenCountFn(truncatedText);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
const finalTokenCount = await tokenCountFn(bestText);
|
||||
|
||||
logger.warn(
|
||||
`[textTokenLimiter] Text truncated from ${originalTokenCount} to ${finalTokenCount} tokens (limit: ${tokenLimit})`,
|
||||
`[textTokenLimiter] Text truncated from ${originalTokenCount} to ${tokenCount} tokens (limit: ${tokenLimit})`,
|
||||
);
|
||||
|
||||
return {
|
||||
text: bestText,
|
||||
tokenCount: finalTokenCount,
|
||||
text: truncatedText,
|
||||
tokenCount,
|
||||
wasTruncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,4 +75,14 @@ class Tokenizer {
|
||||
|
||||
const TokenizerSingleton = new Tokenizer();
|
||||
|
||||
/**
|
||||
* Counts the number of tokens in a given text using tiktoken.
|
||||
* This is an async wrapper around Tokenizer.getTokenCount for compatibility.
|
||||
* @param text - The text to be tokenized. Defaults to an empty string if not provided.
|
||||
* @returns The number of tokens in the provided text.
|
||||
*/
|
||||
export async function countTokens(text = ''): Promise<number> {
|
||||
return TokenizerSingleton.getTokenCount(text, 'cl100k_base');
|
||||
}
|
||||
|
||||
export default TokenizerSingleton;
|
||||
|
||||
@@ -140,6 +140,7 @@ const anthropicModels = {
|
||||
|
||||
const deepseekModels = {
|
||||
deepseek: 128000,
|
||||
'deepseek-chat': 128000,
|
||||
'deepseek-reasoner': 128000,
|
||||
'deepseek-r1': 128000,
|
||||
'deepseek-v3': 128000,
|
||||
@@ -280,6 +281,9 @@ const xAIModels = {
|
||||
'grok-3-mini': 131072,
|
||||
'grok-3-mini-fast': 131072,
|
||||
'grok-4': 256000, // 256K context
|
||||
'grok-4-fast': 2000000, // 2M context
|
||||
'grok-4-1-fast': 2000000, // 2M context (covers reasoning & non-reasoning variants)
|
||||
'grok-code-fast': 256000, // 256K context
|
||||
};
|
||||
|
||||
const aggregateModels = {
|
||||
@@ -344,11 +348,21 @@ const anthropicMaxOutputs = {
|
||||
'claude-3-7-sonnet': 128000,
|
||||
};
|
||||
|
||||
/** Outputs from https://api-docs.deepseek.com/quick_start/pricing */
|
||||
const deepseekMaxOutputs = {
|
||||
deepseek: 8000, // deepseek-chat default: 4K, max: 8K
|
||||
'deepseek-chat': 8000,
|
||||
'deepseek-reasoner': 64000, // default: 32K, max: 64K
|
||||
'deepseek-r1': 64000,
|
||||
'deepseek-v3': 8000,
|
||||
'deepseek.r1': 64000,
|
||||
};
|
||||
|
||||
export const maxOutputTokensMap = {
|
||||
[EModelEndpoint.anthropic]: anthropicMaxOutputs,
|
||||
[EModelEndpoint.azureOpenAI]: modelMaxOutputs,
|
||||
[EModelEndpoint.openAI]: modelMaxOutputs,
|
||||
[EModelEndpoint.custom]: modelMaxOutputs,
|
||||
[EModelEndpoint.openAI]: { ...modelMaxOutputs, ...deepseekMaxOutputs },
|
||||
[EModelEndpoint.custom]: { ...modelMaxOutputs, ...deepseekMaxOutputs },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/client",
|
||||
"version": "0.3.2",
|
||||
"version": "0.4.0",
|
||||
"description": "React components for LibreChat",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
@@ -51,7 +51,7 @@
|
||||
"@tanstack/react-virtual": "^3.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.2.6",
|
||||
"dompurify": "^3.3.0",
|
||||
"framer-motion": "^12.23.6",
|
||||
"i18next": "^24.2.2 || ^25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "librechat-data-provider",
|
||||
"version": "0.8.020",
|
||||
"version": "0.8.100",
|
||||
"description": "data services for librechat apps",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user