Compare commits

..

38 Commits

Author SHA1 Message Date
Danny Avila
007570b5c6 🎨 style: Add missing markdown font size variable to CSS (#9011) 2025-08-12 10:19:29 -04:00
Jeffrey Bulanadi
5943d5346c 📑 docs: Fix Typos in JSDoc and Doc Files (#8998)
- Fix grammar in translations README: 'if has not been ran'  'if it has not been run'
- Fix spacing in JSDoc comments: 'at theend'  'at the end' (2 instances)
2025-08-12 10:18:55 -04:00
Danny Avila
052e61b735 v0.8.0-rc2 (#9000) 2025-08-11 18:58:21 -04:00
Danny Avila
1ccac58403 🔒 fix: Provider Validation for Social, OpenID, SAML, and LDAP Logins (#8999)
* fix: social login provider crossover

* feat: Enhance OpenID login handling and add tests for provider validation

* refactor: authentication error handling to use ErrorTypes.AUTH_FAILED enum

* refactor: update authentication error handling in LDAP and SAML strategies to use ErrorTypes.AUTH_FAILED enum

* ci: Add validation for login with existing email and different provider in SAML strategy

chore: Add logging for existing users with different providers in LDAP, SAML, and Social Login strategies
2025-08-11 18:51:46 -04:00
Clay Rosenthal
04d74a7e07 🪖 ci: Helm OCI Publishing (#7256)
* Adding helm oci publishing (#3)

Signed-off-by: Clay Rosenthal <clayros@amazon.com>

* Update chart version

Signed-off-by: Clay Rosenthal <clayros@amazon.com>

* Update helm release

Signed-off-by: Clay Rosenthal <clayros@amazon.com>

* Update helm chart package path

Signed-off-by: Clay Rosenthal <clayros@amazon.com>

---------

Signed-off-by: Clay Rosenthal <clayros@amazon.com>
2025-08-11 16:21:05 -04:00
github-actions[bot]
0fdca8ddbd 🌍 i18n: Update translation.json with latest translations (#8996)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-11 16:19:37 -04:00
Danny Avila
c5ca621efd 🧑‍💼 feat: Add Agent Model Validation (#8995)
* fix: Update logger import to use data-schemas module

* feat: agent model validation

* fix: Remove invalid error messages from translation file
2025-08-11 14:26:28 -04:00
Danny Avila
8cefa566da 📸 fix: Avatar Handling for Social Login (#8993)
- Updated `handleExistingUser` function to improve avatar handling logic, including checks for manual flags and null/undefined avatars.
- Introduced a new test suite for `handleExistingUser` covering various scenarios, ensuring robust functionality for avatar updates in both local and non-local storage contexts.
2025-08-11 11:50:46 -04:00
Danny Avila
7e4c8a5d0d 🛡️ fix: OTP Verification For 2FA Disable Operation (#8975) 2025-08-10 15:05:16 -04:00
Danny Avila
edf33bedcb 🛂 feat: Payload limits and Validation for User-created Memories (#8974) 2025-08-10 14:46:16 -04:00
Dustin Healy
21e00168b1 🪙 fix: Max Output Tokens Refactor for Responses API (#8972)
🪙 fix: Max Output Tokens Refactor for Responses API (#8972)

chore: Remove `max_output_tokens` from model kwargs in `titleConvo` if provided
2025-08-10 14:08:35 -04:00
github-actions[bot]
da3730b7d6 🌍 i18n: Update translation.json with latest translations (#8957)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-09 12:04:56 -04:00
Danny Avila
770c766d50 🔧 refactor: Move Plugin-related Helpers to TS API and Add Tests (#8961) 2025-08-09 12:02:44 -04:00
alkshmir
5eb6926464 🪖 chore: Fix Typo in helm/librechat/values.yaml (#8960) 2025-08-09 12:00:37 -04:00
Danny Avila
e478ae1c28 📦 chore: Bump @librechat/agents to v2.4.75 (#8956) 2025-08-09 00:01:21 -04:00
Danny Avila
0c9284c8ae 🧠 refactor: Memory Timeout after Completion and Guarantee Stream Final Event (#8955) 2025-08-09 00:01:10 -04:00
Danny Avila
4eeadddfe6 🔮 fix: Artifacts readOnly to Re-render when Expected (#8954) 2025-08-08 22:44:58 -04:00
Dustin Healy
9ca1847535 🔧 refactor: customUserVar Error Normalization (#8950)
* fix: localization string had unused template var

* fix: add normalizeHttpError to hopefully stop UI hangs when an error is returned in UserController

- Ensures updateUserPluginsController always returns valid HTTP status codes instead of undefined
- Add normalizeHttpError() helper to safely extract status/message from errors
- Default to 400 status code when Error.status is undefined/invalid

* refactor: move normalizeHttpError to packages/api
2025-08-08 15:53:04 -04:00
Danny Avila
5d0bc95193 🧪 fix: Editor Styling, Incomplete Artifact Editing, Optimize Artifact Context (#8953)
* refactor: optimize artifacts context for improved performance

* fix: layout classes for artifacts editor

* chore: linting

* fix: enhance artifact mutation handling in CodeEditor to prevent infinite retries

* fix: handle incomplete artifacts in replaceArtifactContent and add regression tests
2025-08-08 15:49:58 -04:00
github-actions[bot]
e7d6100fe4 🌍 i18n: Update translation.json with latest translations (#8934)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-08 12:21:27 -04:00
Danny Avila
01a95229f2 📦 chore: Bump @librechat/agents to v2.4.73 (#8949) 2025-08-08 12:19:36 -04:00
Danny Avila
0939250f07 🛣️ fix: Remove Title Tokens Limit for GPT-5 Models (#8948)
* 🛣️ fix: Remove Title Tokens Limit for GPT-5 Models

* 🛣️ fix: Remove max_completion_tokens from modelKwargs when maxTokens is disabled

* chore: Add test-image* to .gitignore for CI/CD data
2025-08-08 11:15:29 -04:00
Danny Avila
7147bce3c3 feat: Add OpenAI Verbosity Parameter (#8929)
* WIP: Verbosity OpenAI Parameter

* 🔧 chore: remove unused import of extractEnvVariable from parsers.ts

*  feat: add comprehensive tests for getOpenAIConfig and enhance verbosity handling

* fix: Handling for maxTokens in GPT-5+ models and add corresponding tests

* feat: Implement GPT-5+ model handling in processMemory function
2025-08-07 20:49:40 -04:00
github-actions[bot]
486fe34a2b 🌍 i18n: Update translation.json with latest translations (#8924)
* 🌍 i18n: Update translation.json with latest translations

* Update translation.json

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-08-07 20:42:44 -04:00
github-actions[bot]
922f43f520 🌍 i18n: Update translation.json with latest translations (#8907)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-07 16:25:24 -04:00
Marco Beretta
e6fa01d514 💬 style: Enhance Tooltip with HTML support and Improve Styling (#8915)
*  feat: Enhance Tooltip component with HTML support and styling improvements

*  feat: Integrate DOMPurify for HTML sanitization in Tooltip component
2025-08-07 16:24:42 -04:00
Danny Avila
8238fb49e0 📦 chore: bump @librechat/agents to v2.4.72 2025-08-07 16:23:12 -04:00
Danny Avila
430557676d feat: GPT-5 Token Limits, Rates, Icon, Reasoning Support 2025-08-07 16:23:11 -04:00
Danny Avila
8a5047c456 📦 chore: bump @librechat/agents to v2.4.71 2025-08-07 16:23:11 -04:00
Danny Avila
c787515894 🧠 feat: Add minimal Reasoning Effort option 2025-08-07 16:23:11 -04:00
Danny Avila
d95d8032cc feat: GPT-OSS models Token Limits & Rates 2025-08-07 16:23:10 -04:00
Joseph Licata
b9f72f4869 🎚️ refactor: Update Min. Values for OpenAI Parameters (#8922) 2025-08-07 14:38:08 -04:00
Danny Avila
429bb6653a 📦 chore: bump @librechat/agents to v2.4.70 (#8923) 2025-08-07 14:36:10 -04:00
Dustin Healy
47caafa8f8 🔧 fix: MCP Queries and Connections (#8870)
* fix: add refetchQueries on connection success so ToolSelectDialog doesn't require hard refresh

* fix: change hook so we only query connection status when mcpServers are configured

* fix: change refetchQueries to invalidateQueries for tools after server connection update

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-08-07 02:31:05 -04:00
Dustin Healy
8530594f37 🟢 fix: Incorrect customUserVars Set States (#8905) 2025-08-07 02:19:06 -04:00
Sebastien Bruel
0b071c06f6 🥞 refactor: Duplicate Agent Versions as Informational Instead of Errors (#8881)
* Fix error when updating an agent with no changes

* Add tests

* Revert translation file changes
2025-08-07 02:12:05 -04:00
Danny Avila
1092392ed8 📂 fix: File Cleanup for Uploaded "Agent" Files (#8900) 2025-08-06 19:45:57 -04:00
Danny Avila
36c8947029 🔄 refactor: Select OpenRouter LLM Class Dynamically by baseURL (#8898) 2025-08-06 19:26:40 -04:00
127 changed files with 4372 additions and 832 deletions

View File

@@ -4,12 +4,13 @@ name: Build Helm Charts on Tag
on:
push:
tags:
- "*"
- "chart-*"
jobs:
release:
permissions:
contents: write
packages: write
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -26,15 +27,49 @@ jobs:
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Build Subchart Deps
run: |
cd helm/librechat-rag-api
helm dependency build
cd helm/librechat
helm dependency build
cd ../librechat-rag-api
helm dependency build
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.6.0
- name: Get Chart Version
id: chart-version
run: |
CHART_VERSION=$(echo "${{ github.ref_name }}" | cut -d'-' -f2)
echo "CHART_VERSION=${CHART_VERSION}" >> "$GITHUB_OUTPUT"
# Log in to GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
charts_dir: helm
skip_existing: true
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Run Helm OCI Charts Releaser
# This is for the librechat chart
- name: Release Helm OCI Charts for librechat
uses: appany/helm-oci-chart-releaser@v0.4.2
with:
name: librechat
repository: ${{ github.actor }}/librechat-chart
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
path: helm/librechat
registry: ghcr.io
registry_username: ${{ github.actor }}
registry_password: ${{ secrets.GITHUB_TOKEN }}
# this is for the librechat-rag-api chart
- name: Release Helm OCI Charts for librechat-rag-api
uses: appany/helm-oci-chart-releaser@v0.4.2
with:
name: librechat-rag-api
repository: ${{ github.actor }}/librechat-chart
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
path: helm/librechat-rag-api
registry: ghcr.io
registry_username: ${{ github.actor }}
registry_password: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -13,6 +13,9 @@ pids
*.seed
.git
# CI/CD data
test-image*
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

View File

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

View File

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

View File

@@ -1222,7 +1222,9 @@ ${convo}
}
if (this.isOmni === true && modelOptions.max_tokens != null) {
modelOptions.max_completion_tokens = modelOptions.max_tokens;
const paramName =
modelOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
modelOptions[paramName] = modelOptions.max_tokens;
delete modelOptions.max_tokens;
}
if (this.isOmni === true && modelOptions.temperature != null) {

View File

@@ -316,17 +316,10 @@ const updateAgent = async (searchParameter, updateData, options = {}) => {
if (shouldCreateVersion) {
const duplicateVersion = isDuplicateVersion(updateData, versionData, versions, actionsHash);
if (duplicateVersion && !forceVersion) {
const error = new Error(
'Duplicate version: This would create a version identical to an existing one',
);
error.statusCode = 409;
error.details = {
duplicateVersion,
versionIndex: versions.findIndex(
(v) => JSON.stringify(duplicateVersion) === JSON.stringify(v),
),
};
throw error;
// No changes detected, return the current agent without creating a new version
const agentObj = currentAgent.toObject();
agentObj.version = versions.length;
return agentObj;
}
}

View File

@@ -879,45 +879,31 @@ describe('models/Agent', () => {
expect(emptyParamsAgent.model_parameters).toEqual({});
});
test('should detect duplicate versions and reject updates', async () => {
const originalConsoleError = console.error;
console.error = jest.fn();
test('should not create new version for duplicate updates', async () => {
const authorId = new mongoose.Types.ObjectId();
const testCases = generateVersionTestCases();
try {
const authorId = new mongoose.Types.ObjectId();
const testCases = generateVersionTestCases();
for (const testCase of testCases) {
const testAgentId = `agent_${uuidv4()}`;
for (const testCase of testCases) {
const testAgentId = `agent_${uuidv4()}`;
await createAgent({
id: testAgentId,
provider: 'test',
model: 'test-model',
author: authorId,
...testCase.initial,
});
await createAgent({
id: testAgentId,
provider: 'test',
model: 'test-model',
author: authorId,
...testCase.initial,
});
const updatedAgent = await updateAgent({ id: testAgentId }, testCase.update);
expect(updatedAgent.versions).toHaveLength(2); // No new version created
await updateAgent({ id: testAgentId }, testCase.update);
// Update with duplicate data should succeed but not create a new version
const duplicateUpdate = await updateAgent({ id: testAgentId }, testCase.duplicate);
let error;
try {
await updateAgent({ id: testAgentId }, testCase.duplicate);
} catch (e) {
error = e;
}
expect(duplicateUpdate.versions).toHaveLength(2); // No new version created
expect(error).toBeDefined();
expect(error.message).toContain('Duplicate version');
expect(error.statusCode).toBe(409);
expect(error.details).toBeDefined();
expect(error.details.duplicateVersion).toBeDefined();
const agent = await getAgent({ id: testAgentId });
expect(agent.versions).toHaveLength(2);
}
} finally {
console.error = originalConsoleError;
const agent = await getAgent({ id: testAgentId });
expect(agent.versions).toHaveLength(2);
}
});
@@ -1093,20 +1079,13 @@ describe('models/Agent', () => {
expect(secondUpdate.versions).toHaveLength(3);
// Update without forceVersion and no changes should not create a version
let error;
try {
await updateAgent(
{ id: agentId },
{ tools: ['listEvents_action_test.com', 'createEvent_action_test.com'] },
{ updatingUserId: authorId.toString(), forceVersion: false },
);
} catch (e) {
error = e;
}
const duplicateUpdate = await updateAgent(
{ id: agentId },
{ tools: ['listEvents_action_test.com', 'createEvent_action_test.com'] },
{ updatingUserId: authorId.toString(), forceVersion: false },
);
expect(error).toBeDefined();
expect(error.message).toContain('Duplicate version');
expect(error.statusCode).toBe(409);
expect(duplicateUpdate.versions).toHaveLength(3); // No new version created
});
test('should handle isDuplicateVersion with arrays containing null/undefined values', async () => {
@@ -2400,11 +2379,18 @@ describe('models/Agent', () => {
agent_ids: ['agent1', 'agent2'],
});
await updateAgent({ id: agentId }, { agent_ids: ['agent1', 'agent2', 'agent3'] });
const updatedAgent = await updateAgent(
{ id: agentId },
{ agent_ids: ['agent1', 'agent2', 'agent3'] },
);
expect(updatedAgent.versions).toHaveLength(2);
await expect(
updateAgent({ id: agentId }, { agent_ids: ['agent1', 'agent2', 'agent3'] }),
).rejects.toThrow('Duplicate version');
// Update with same agent_ids should succeed but not create a new version
const duplicateUpdate = await updateAgent(
{ id: agentId },
{ agent_ids: ['agent1', 'agent2', 'agent3'] },
);
expect(duplicateUpdate.versions).toHaveLength(2); // No new version created
});
test('should handle agent_ids field alongside other fields', async () => {
@@ -2543,9 +2529,10 @@ describe('models/Agent', () => {
expect(updated.versions).toHaveLength(2);
expect(updated.agent_ids).toEqual([]);
await expect(updateAgent({ id: agentId }, { agent_ids: [] })).rejects.toThrow(
'Duplicate version',
);
// Update with same empty agent_ids should succeed but not create a new version
const duplicateUpdate = await updateAgent({ id: agentId }, { agent_ids: [] });
expect(duplicateUpdate.versions).toHaveLength(2); // No new version created
expect(duplicateUpdate.agent_ids).toEqual([]);
});
test('should handle agent without agent_ids field', async () => {

View File

@@ -1,4 +1,4 @@
const { matchModelName } = require('../utils');
const { matchModelName } = require('../utils/tokens');
const defaultRate = 6;
/**
@@ -87,6 +87,9 @@ const tokenValues = Object.assign(
'gpt-4.1': { prompt: 2, completion: 8 },
'gpt-4.5': { prompt: 75, completion: 150 },
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
'gpt-5': { prompt: 1.25, completion: 10 },
'gpt-5-mini': { prompt: 0.25, completion: 2 },
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
'gpt-4o': { prompt: 2.5, completion: 10 },
'gpt-4o-2024-05-13': { prompt: 5, completion: 15 },
'gpt-4-1106': { prompt: 10, completion: 30 },
@@ -147,6 +150,9 @@ const tokenValues = Object.assign(
codestral: { prompt: 0.3, completion: 0.9 },
'ministral-8b': { prompt: 0.1, completion: 0.1 },
'ministral-3b': { prompt: 0.04, completion: 0.04 },
// GPT-OSS models
'gpt-oss-20b': { prompt: 0.05, completion: 0.2 },
'gpt-oss-120b': { prompt: 0.15, completion: 0.6 },
},
bedrockValues,
);
@@ -214,6 +220,12 @@ const getValueKey = (model, endpoint) => {
return 'gpt-4.1';
} else if (modelName.includes('gpt-4o-2024-05-13')) {
return 'gpt-4o-2024-05-13';
} else if (modelName.includes('gpt-5-nano')) {
return 'gpt-5-nano';
} else if (modelName.includes('gpt-5-mini')) {
return 'gpt-5-mini';
} else if (modelName.includes('gpt-5')) {
return 'gpt-5';
} else if (modelName.includes('gpt-4o-mini')) {
return 'gpt-4o-mini';
} else if (modelName.includes('gpt-4o')) {

View File

@@ -25,8 +25,14 @@ describe('getValueKey', () => {
expect(getValueKey('gpt-4-some-other-info')).toBe('8k');
});
it('should return undefined for model names that do not match any known patterns', () => {
expect(getValueKey('gpt-5-some-other-info')).toBeUndefined();
it('should return "gpt-5" for model name containing "gpt-5"', () => {
expect(getValueKey('gpt-5-some-other-info')).toBe('gpt-5');
expect(getValueKey('gpt-5-2025-01-30')).toBe('gpt-5');
expect(getValueKey('gpt-5-2025-01-30-0130')).toBe('gpt-5');
expect(getValueKey('openai/gpt-5')).toBe('gpt-5');
expect(getValueKey('openai/gpt-5-2025-01-30')).toBe('gpt-5');
expect(getValueKey('gpt-5-turbo')).toBe('gpt-5');
expect(getValueKey('gpt-5-0130')).toBe('gpt-5');
});
it('should return "gpt-3.5-turbo-1106" for model name containing "gpt-3.5-turbo-1106"', () => {
@@ -84,6 +90,29 @@ describe('getValueKey', () => {
expect(getValueKey('gpt-4.1-nano-0125')).toBe('gpt-4.1-nano');
});
it('should return "gpt-5" for model type of "gpt-5"', () => {
expect(getValueKey('gpt-5-2025-01-30')).toBe('gpt-5');
expect(getValueKey('gpt-5-2025-01-30-0130')).toBe('gpt-5');
expect(getValueKey('openai/gpt-5')).toBe('gpt-5');
expect(getValueKey('openai/gpt-5-2025-01-30')).toBe('gpt-5');
expect(getValueKey('gpt-5-turbo')).toBe('gpt-5');
expect(getValueKey('gpt-5-0130')).toBe('gpt-5');
});
it('should return "gpt-5-mini" for model type of "gpt-5-mini"', () => {
expect(getValueKey('gpt-5-mini-2025-01-30')).toBe('gpt-5-mini');
expect(getValueKey('openai/gpt-5-mini')).toBe('gpt-5-mini');
expect(getValueKey('gpt-5-mini-0130')).toBe('gpt-5-mini');
expect(getValueKey('gpt-5-mini-2025-01-30-0130')).toBe('gpt-5-mini');
});
it('should return "gpt-5-nano" for model type of "gpt-5-nano"', () => {
expect(getValueKey('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano');
expect(getValueKey('openai/gpt-5-nano')).toBe('gpt-5-nano');
expect(getValueKey('gpt-5-nano-0130')).toBe('gpt-5-nano');
expect(getValueKey('gpt-5-nano-2025-01-30-0130')).toBe('gpt-5-nano');
});
it('should return "gpt-4o" for model type of "gpt-4o"', () => {
expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o');
expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o');
@@ -207,6 +236,48 @@ describe('getMultiplier', () => {
);
});
it('should return the correct multiplier for gpt-5', () => {
const valueKey = getValueKey('gpt-5-2025-01-30');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5'].prompt);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-5'].completion,
);
expect(getMultiplier({ model: 'gpt-5-preview', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-5', tokenType: 'completion' })).toBe(
tokenValues['gpt-5'].completion,
);
});
it('should return the correct multiplier for gpt-5-mini', () => {
const valueKey = getValueKey('gpt-5-mini-2025-01-30');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-mini'].prompt);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-5-mini'].completion,
);
expect(getMultiplier({ model: 'gpt-5-mini-preview', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5-mini'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-5-mini', tokenType: 'completion' })).toBe(
tokenValues['gpt-5-mini'].completion,
);
});
it('should return the correct multiplier for gpt-5-nano', () => {
const valueKey = getValueKey('gpt-5-nano-2025-01-30');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-nano'].prompt);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-5-nano'].completion,
);
expect(getMultiplier({ model: 'gpt-5-nano-preview', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5-nano'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-5-nano', tokenType: 'completion' })).toBe(
tokenValues['gpt-5-nano'].completion,
);
});
it('should return the correct multiplier for gpt-4o', () => {
const valueKey = getValueKey('gpt-4o-2024-08-06');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
@@ -307,10 +378,22 @@ describe('getMultiplier', () => {
});
it('should return defaultRate if derived valueKey does not match any known patterns', () => {
expect(getMultiplier({ tokenType: 'prompt', model: 'gpt-5-some-other-info' })).toBe(
expect(getMultiplier({ tokenType: 'prompt', model: 'gpt-10-some-other-info' })).toBe(
defaultRate,
);
});
it('should return correct multipliers for GPT-OSS models', () => {
const models = ['gpt-oss-20b', 'gpt-oss-120b'];
models.forEach((key) => {
const expectedPrompt = tokenValues[key].prompt;
const expectedCompletion = tokenValues[key].completion;
expect(getMultiplier({ valueKey: key, tokenType: 'prompt' })).toBe(expectedPrompt);
expect(getMultiplier({ valueKey: key, tokenType: 'completion' })).toBe(expectedCompletion);
expect(getMultiplier({ model: key, tokenType: 'prompt' })).toBe(expectedPrompt);
expect(getMultiplier({ model: key, tokenType: 'completion' })).toBe(expectedCompletion);
});
});
});
describe('AWS Bedrock Model Tests', () => {

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.0-rc1",
"version": "v0.8.0-rc2",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -49,7 +49,7 @@
"@langchain/google-vertexai": "^0.2.13",
"@langchain/openai": "^0.5.18",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.69",
"@librechat/agents": "^2.4.75",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.17.1",

View File

@@ -1,46 +0,0 @@
const { logger } = require('~/config');
//handle duplicates
const handleDuplicateKeyError = (err, res) => {
logger.error('Duplicate key error:', err.keyValue);
const field = `${JSON.stringify(Object.keys(err.keyValue))}`;
const code = 409;
res
.status(code)
.send({ messages: `An document with that ${field} already exists.`, fields: field });
};
//handle validation errors
const handleValidationError = (err, res) => {
logger.error('Validation error:', err.errors);
let errors = Object.values(err.errors).map((el) => el.message);
let fields = `${JSON.stringify(Object.values(err.errors).map((el) => el.path))}`;
let code = 400;
if (errors.length > 1) {
errors = errors.join(' ');
res.status(code).send({ messages: `${JSON.stringify(errors)}`, fields: fields });
} else {
res.status(code).send({ messages: `${JSON.stringify(errors)}`, fields: fields });
}
};
module.exports = (err, _req, res, _next) => {
try {
if (err.name === 'ValidationError') {
return handleValidationError(err, res);
}
if (err.code && err.code == 11000) {
return handleDuplicateKeyError(err, res);
}
// Special handling for errors like SyntaxError
if (err.statusCode && err.body) {
return res.status(err.statusCode).send(err.body);
}
logger.error('ErrorController => error', err);
return res.status(500).send('An unknown error occurred.');
} catch (err) {
logger.error('ErrorController => processing error', err);
return res.status(500).send('Processing error in ErrorController.');
}
};

View File

@@ -5,6 +5,7 @@ const { logger } = require('~/config');
/**
* @param {ServerRequest} req
* @returns {Promise<TModelsConfig>} The models config.
*/
const getModelsConfig = async (req) => {
const cache = getLogStores(CacheKeys.CONFIG_STORE);

View File

@@ -1,54 +1,16 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, AuthType, Constants } = require('librechat-data-provider');
const { CacheKeys, Constants } = require('librechat-data-provider');
const {
getToolkitKey,
checkPluginAuth,
filterUniquePlugins,
convertMCPToolsToPlugins,
} = require('@librechat/api');
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
const { getToolkitKey } = require('~/server/services/ToolService');
const { availableTools, toolkits } = require('~/app/clients/tools');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { availableTools } = require('~/app/clients/tools');
const { getLogStores } = require('~/cache');
/**
* Filters out duplicate plugins from the list of plugins.
*
* @param {TPlugin[]} plugins The list of plugins to filter.
* @returns {TPlugin[]} The list of plugins with duplicates removed.
*/
const filterUniquePlugins = (plugins) => {
const seen = new Set();
return plugins.filter((plugin) => {
const duplicate = seen.has(plugin.pluginKey);
seen.add(plugin.pluginKey);
return !duplicate;
});
};
/**
* Determines if a plugin is authenticated by checking if all required authentication fields have non-empty values.
* Supports alternate authentication fields, allowing validation against multiple possible environment variables.
*
* @param {TPlugin} plugin The plugin object containing the authentication configuration.
* @returns {boolean} True if the plugin is authenticated for all required fields, false otherwise.
*/
const checkPluginAuth = (plugin) => {
if (!plugin.authConfig || plugin.authConfig.length === 0) {
return false;
}
return plugin.authConfig.every((authFieldObj) => {
const authFieldOptions = authFieldObj.authField.split('||');
let isFieldAuthenticated = false;
for (const fieldOption of authFieldOptions) {
const envValue = process.env[fieldOption];
if (envValue && envValue.trim() !== '' && envValue !== AuthType.USER_PROVIDED) {
isFieldAuthenticated = true;
break;
}
}
return isFieldAuthenticated;
});
};
const getAvailablePluginsController = async (req, res) => {
try {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
@@ -143,9 +105,9 @@ const getAvailableTools = async (req, res) => {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
const cachedUserTools = await getCachedTools({ userId });
const userPlugins = convertMCPToolsToPlugins(cachedUserTools, customConfig);
const userPlugins = convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig });
if (cachedToolsArray && userPlugins) {
if (cachedToolsArray != null && userPlugins != null) {
const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]);
res.status(200).json(dedupedTools);
return;
@@ -185,7 +147,9 @@ const getAvailableTools = async (req, res) => {
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
const isToolkit =
plugin.toolkit === true &&
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey);
Object.keys(toolDefinitions).some(
(key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey,
);
if (!isToolDefined && !isToolkit) {
continue;
@@ -235,58 +199,6 @@ const getAvailableTools = async (req, res) => {
}
};
/**
* Converts MCP function format tools to plugin format
* @param {Object} functionTools - Object with function format tools
* @param {Object} customConfig - Custom configuration for MCP servers
* @returns {Array} Array of plugin objects
*/
function convertMCPToolsToPlugins(functionTools, customConfig) {
const plugins = [];
for (const [toolKey, toolData] of Object.entries(functionTools)) {
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
continue;
}
const functionData = toolData.function;
const parts = toolKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName];
const plugin = {
name: parts[0], // Use the tool name without server suffix
pluginKey: toolKey,
description: functionData.description || '',
authenticated: true,
icon: serverConfig?.iconPath,
};
// Build authConfig for MCP tools
if (!serverConfig?.customUserVars) {
plugin.authConfig = [];
plugins.push(plugin);
continue;
}
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length === 0) {
plugin.authConfig = [];
} else {
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
}));
}
plugins.push(plugin);
}
return plugins;
}
module.exports = {
getAvailableTools,
getAvailablePluginsController,

View File

@@ -28,19 +28,211 @@ jest.mock('~/config', () => ({
jest.mock('~/app/clients/tools', () => ({
availableTools: [],
toolkits: [],
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
getToolkitKey: jest.fn(),
checkPluginAuth: jest.fn(),
filterUniquePlugins: jest.fn(),
convertMCPToolsToPlugins: jest.fn(),
}));
// Import the actual module with the function we want to test
const { getAvailableTools } = require('./PluginController');
const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
const {
filterUniquePlugins,
checkPluginAuth,
convertMCPToolsToPlugins,
getToolkitKey,
} = require('@librechat/api');
describe('PluginController', () => {
describe('plugin.icon behavior', () => {
let mockReq, mockRes, mockCache;
let mockReq, mockRes, mockCache;
beforeEach(() => {
jest.clearAllMocks();
mockReq = { user: { id: 'test-user-id' } };
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
mockCache = { get: jest.fn(), set: jest.fn() };
getLogStores.mockReturnValue(mockCache);
});
describe('getAvailablePluginsController', () => {
beforeEach(() => {
mockReq.app = { locals: { filteredTools: [], includedTools: [] } };
});
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
];
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue(mockPlugins);
checkPluginAuth.mockReturnValue(true);
await getAvailablePluginsController(mockReq, mockRes);
expect(filterUniquePlugins).toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(200);
// The response includes authenticated: true for each plugin when checkPluginAuth returns true
expect(mockRes.json).toHaveBeenCalledWith([
{ name: 'Plugin1', pluginKey: 'key1', description: 'First', authenticated: true },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second', authenticated: true },
]);
});
it('should use checkPluginAuth to verify plugin authentication', async () => {
const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue([mockPlugin]);
checkPluginAuth.mockReturnValueOnce(true);
await getAvailablePluginsController(mockReq, mockRes);
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData[0].authenticated).toBe(true);
});
it('should return cached plugins when available', async () => {
const cachedPlugins = [
{ name: 'CachedPlugin', pluginKey: 'cached', description: 'Cached plugin' },
];
mockCache.get.mockResolvedValue(cachedPlugins);
await getAvailablePluginsController(mockReq, mockRes);
expect(filterUniquePlugins).not.toHaveBeenCalled();
expect(checkPluginAuth).not.toHaveBeenCalled();
expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins);
});
it('should filter plugins based on includedTools', async () => {
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
];
mockReq.app.locals.includedTools = ['key1'];
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue(mockPlugins);
checkPluginAuth.mockReturnValue(false);
await getAvailablePluginsController(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(1);
expect(responseData[0].pluginKey).toBe('key1');
});
});
describe('getAvailableTools', () => {
it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => {
const mockUserTools = {
[`tool1${Constants.mcp_delimiter}server1`]: {
function: { name: 'tool1', description: 'Tool 1' },
},
};
const mockConvertedPlugins = [
{
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}server1`,
description: 'Tool 1',
},
];
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce(mockUserTools);
convertMCPToolsToPlugins.mockReturnValue(mockConvertedPlugins);
filterUniquePlugins.mockImplementation((plugins) => plugins);
getCustomConfig.mockResolvedValue(null);
await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: mockUserTools,
customConfig: null,
});
});
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
const mockUserPlugins = [
{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' },
];
const mockManifestPlugins = [
{ name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
];
mockCache.get.mockResolvedValue(mockManifestPlugins);
getCachedTools.mockResolvedValueOnce({});
convertMCPToolsToPlugins.mockReturnValue(mockUserPlugins);
filterUniquePlugins.mockReturnValue([...mockUserPlugins, ...mockManifestPlugins]);
getCustomConfig.mockResolvedValue(null);
await getAvailableTools(mockReq, mockRes);
// Should be called to deduplicate the combined array
expect(filterUniquePlugins).toHaveBeenLastCalledWith([
...mockUserPlugins,
...mockManifestPlugins,
]);
});
it('should use checkPluginAuth to verify authentication status', async () => {
const mockPlugin = { name: 'Tool1', pluginKey: 'tool1', description: 'Tool 1' };
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockPlugin]);
checkPluginAuth.mockReturnValue(true);
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return tool definitions
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({ tool1: true });
await getAvailableTools(mockReq, mockRes);
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
});
it('should use getToolkitKey for toolkit validation', async () => {
const mockToolkit = {
name: 'Toolkit1',
pluginKey: 'toolkit1',
description: 'Toolkit 1',
toolkit: true,
};
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockToolkit]);
checkPluginAuth.mockReturnValue(false);
getToolkitKey.mockReturnValue('toolkit1');
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return tool definitions
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({
toolkit1_function: true,
});
await getAvailableTools(mockReq, mockRes);
expect(getToolkitKey).toHaveBeenCalled();
});
});
describe('plugin.icon behavior', () => {
const callGetAvailableToolsWithMCPServer = async (mcpServers) => {
mockCache.get.mockResolvedValue(null);
getCustomConfig.mockResolvedValue({ mcpServers });
@@ -50,7 +242,22 @@ describe('PluginController', () => {
function: { name: 'test-tool', description: 'A test tool' },
},
};
const mockConvertedPlugin = {
name: 'test-tool',
pluginKey: `test-tool${Constants.mcp_delimiter}test-server`,
description: 'A test tool',
icon: mcpServers['test-server']?.iconPath,
authenticated: true,
authConfig: [],
};
getCachedTools.mockResolvedValueOnce(functionTools);
convertMCPToolsToPlugins.mockReturnValue([mockConvertedPlugin]);
filterUniquePlugins.mockImplementation((plugins) => plugins);
checkPluginAuth.mockReturnValue(true);
getToolkitKey.mockReturnValue(undefined);
getCachedTools.mockResolvedValueOnce({
[`test-tool${Constants.mcp_delimiter}test-server`]: true,
});
@@ -60,14 +267,6 @@ describe('PluginController', () => {
return responseData.find((tool) => tool.name === 'test-tool');
};
beforeEach(() => {
jest.clearAllMocks();
mockReq = { user: { id: 'test-user-id' } };
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
mockCache = { get: jest.fn(), set: jest.fn() };
getLogStores.mockReturnValue(mockCache);
});
it('should set plugin.icon when iconPath is defined', async () => {
const mcpServers = {
'test-server': {
@@ -86,4 +285,236 @@ describe('PluginController', () => {
expect(testTool.icon).toBeUndefined();
});
});
describe('helper function integration', () => {
it('should properly handle MCP tools with custom user variables', async () => {
const customConfig = {
mcpServers: {
'test-server': {
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' },
},
},
},
};
// We need to test the actual flow where MCP manager tools are included
const mcpManagerTools = [
{
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
description: 'Tool 1',
authenticated: true,
},
];
// Mock the MCP manager to return tools
const mockMCPManager = {
loadManifestTools: jest.fn().mockResolvedValue(mcpManagerTools),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
mockCache.get.mockResolvedValue(null);
getCustomConfig.mockResolvedValue(customConfig);
// First call returns user tools (empty in this case)
getCachedTools.mockResolvedValueOnce({});
// Mock convertMCPToolsToPlugins to return empty array for user tools
convertMCPToolsToPlugins.mockReturnValue([]);
// Mock filterUniquePlugins to pass through
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
// Mock checkPluginAuth
checkPluginAuth.mockReturnValue(true);
// Second call returns tool definitions
getCachedTools.mockResolvedValueOnce({
[`tool1${Constants.mcp_delimiter}test-server`]: true,
});
await getAvailableTools(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
// Find the MCP tool in the response
const mcpTool = responseData.find(
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}test-server`,
);
// The actual implementation adds authConfig and sets authenticated to false when customUserVars exist
expect(mcpTool).toBeDefined();
expect(mcpTool.authConfig).toEqual([
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
]);
expect(mcpTool.authenticated).toBe(false);
});
it('should handle error cases gracefully', async () => {
mockCache.get.mockRejectedValue(new Error('Cache error'));
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Cache error' });
});
});
describe('edge cases with undefined/null values', () => {
it('should handle undefined cache gracefully', async () => {
getLogStores.mockReturnValue(undefined);
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
});
it('should handle null cachedTools and cachedUserTools', async () => {
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue(null);
convertMCPToolsToPlugins.mockReturnValue(undefined);
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: null,
customConfig: null,
});
});
it('should handle when getCachedTools returns undefined', async () => {
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue(undefined);
convertMCPToolsToPlugins.mockReturnValue(undefined);
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
checkPluginAuth.mockReturnValue(false);
// Mock getCachedTools to return undefined for both calls
getCachedTools.mockReset();
getCachedTools.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined);
await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: undefined,
customConfig: null,
});
});
it('should handle cachedToolsArray and userPlugins both being defined', async () => {
const cachedTools = [{ name: 'CachedTool', pluginKey: 'cached-tool', description: 'Cached' }];
const userTools = {
'user-tool': { function: { name: 'user-tool', description: 'User tool' } },
};
const userPlugins = [{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' }];
mockCache.get.mockResolvedValue(cachedTools);
getCachedTools.mockResolvedValue(userTools);
convertMCPToolsToPlugins.mockReturnValue(userPlugins);
filterUniquePlugins.mockReturnValue([...userPlugins, ...cachedTools]);
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([...userPlugins, ...cachedTools]);
});
it('should handle empty toolDefinitions object', async () => {
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
checkPluginAuth.mockReturnValue(true);
await getAvailableTools(mockReq, mockRes);
// With empty tool definitions, no tools should be in the final output
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle MCP tools without customUserVars', async () => {
const customConfig = {
mcpServers: {
'test-server': {
// No customUserVars defined
},
},
};
const mockUserTools = {
[`tool1${Constants.mcp_delimiter}test-server`]: {
function: { name: 'tool1', description: 'Tool 1' },
},
};
mockCache.get.mockResolvedValue(null);
getCustomConfig.mockResolvedValue(customConfig);
getCachedTools.mockResolvedValueOnce(mockUserTools);
const mockPlugin = {
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
description: 'Tool 1',
authenticated: true,
authConfig: [],
};
convertMCPToolsToPlugins.mockReturnValue([mockPlugin]);
filterUniquePlugins.mockImplementation((plugins) => plugins);
checkPluginAuth.mockReturnValue(true);
getCachedTools.mockResolvedValueOnce({
[`tool1${Constants.mcp_delimiter}test-server`]: true,
});
await getAvailableTools(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData[0].authenticated).toBe(true);
// The actual implementation doesn't set authConfig on tools without customUserVars
expect(responseData[0].authConfig).toEqual([]);
});
it('should handle req.app.locals with undefined filteredTools and includedTools', async () => {
mockReq.app = { locals: {} };
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue([]);
checkPluginAuth.mockReturnValue(false);
await getAvailablePluginsController(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle toolkit with undefined toolDefinitions keys', async () => {
const mockToolkit = {
name: 'Toolkit1',
pluginKey: 'toolkit1',
description: 'Toolkit 1',
toolkit: true,
};
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockToolkit]);
checkPluginAuth.mockReturnValue(false);
getToolkitKey.mockReturnValue(undefined);
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return null
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce(null);
await getAvailableTools(mockReq, mockRes);
// Should handle null toolDefinitions gracefully
expect(mockRes.status).toHaveBeenCalledWith(200);
});
});
});

View File

@@ -99,10 +99,36 @@ const confirm2FA = async (req, res) => {
/**
* Disable 2FA by clearing the stored secret and backup codes.
* Requires verification with either TOTP token or backup code if 2FA is fully enabled.
*/
const disable2FA = async (req, res) => {
try {
const userId = req.user.id;
const { token, backupCode } = req.body;
const user = await getUserById(userId);
if (!user || !user.totpSecret) {
return res.status(400).json({ message: '2FA is not setup for this user' });
}
if (user.twoFactorEnabled) {
const secret = await getTOTPSecret(user.totpSecret);
let isVerified = false;
if (token) {
isVerified = await verifyTOTP(secret, token);
} else if (backupCode) {
isVerified = await verifyBackupCode({ user, backupCode });
} else {
return res
.status(400)
.json({ message: 'Either token or backup code is required to disable 2FA' });
}
if (!isVerified) {
return res.status(401).json({ message: 'Invalid token or backup code' });
}
}
await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false });
return res.status(200).json();
} catch (err) {

View File

@@ -1,5 +1,5 @@
const { logger } = require('@librechat/data-schemas');
const { webSearchKeys, extractWebSearchEnvVars } = require('@librechat/api');
const { webSearchKeys, extractWebSearchEnvVars, normalizeHttpError } = require('@librechat/api');
const {
getFiles,
updateUser,
@@ -89,8 +89,8 @@ const updateUserPluginsController = async (req, res) => {
if (userPluginsService instanceof Error) {
logger.error('[userPluginsService]', userPluginsService);
const { status, message } = userPluginsService;
res.status(status).send({ message });
const { status, message } = normalizeHttpError(userPluginsService);
return res.status(status).send({ message });
}
}
@@ -137,7 +137,7 @@ const updateUserPluginsController = async (req, res) => {
authService = await updateUserPluginAuth(user.id, keys[i], pluginKey, values[i]);
if (authService instanceof Error) {
logger.error('[authService]', authService);
({ status, message } = authService);
({ status, message } = normalizeHttpError(authService));
}
}
} else if (action === 'uninstall') {
@@ -151,7 +151,7 @@ const updateUserPluginsController = async (req, res) => {
`[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
authService,
);
({ status, message } = authService);
({ status, message } = normalizeHttpError(authService));
}
} else {
// This handles:
@@ -163,7 +163,7 @@ const updateUserPluginsController = async (req, res) => {
authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name
if (authService instanceof Error) {
logger.error('[authService] Error deleting specific auth key:', authService);
({ status, message } = authService);
({ status, message } = normalizeHttpError(authService));
}
}
}
@@ -193,7 +193,8 @@ const updateUserPluginsController = async (req, res) => {
return res.status(status).send();
}
res.status(status).send({ message });
const normalized = normalizeHttpError({ status, message });
return res.status(normalized.status).send({ message: normalized.message });
} catch (err) {
logger.error('[updateUserPluginsController]', err);
return res.status(500).json({ message: 'Something went wrong.' });

View File

@@ -402,6 +402,34 @@ class AgentClient extends BaseClient {
return result;
}
/**
* Creates a promise that resolves with the memory promise result or undefined after a timeout
* @param {Promise<(TAttachment | null)[] | undefined>} memoryPromise - The memory promise to await
* @param {number} timeoutMs - Timeout in milliseconds (default: 3000)
* @returns {Promise<(TAttachment | null)[] | undefined>}
*/
async awaitMemoryWithTimeout(memoryPromise, timeoutMs = 3000) {
if (!memoryPromise) {
return;
}
try {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Memory processing timeout')), timeoutMs),
);
const attachments = await Promise.race([memoryPromise, timeoutPromise]);
return attachments;
} catch (error) {
if (error.message === 'Memory processing timeout') {
logger.warn('[AgentClient] Memory processing timed out after 3 seconds');
} else {
logger.error('[AgentClient] Error processing memory:', error);
}
return;
}
}
/**
* @returns {Promise<string | undefined>}
*/
@@ -1002,11 +1030,9 @@ class AgentClient extends BaseClient {
});
try {
if (memoryPromise) {
const attachments = await memoryPromise;
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
await this.recordCollectedUsage({ context: 'message' });
} catch (err) {
@@ -1016,11 +1042,9 @@ class AgentClient extends BaseClient {
);
}
} catch (err) {
if (memoryPromise) {
const attachments = await memoryPromise;
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
logger.error(
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
@@ -1122,11 +1146,16 @@ class AgentClient extends BaseClient {
clientOptions.configuration = options.configOptions;
}
// Ensure maxTokens is set for non-o1 models
if (!/\b(o\d)\b/i.test(clientOptions.model) && !clientOptions.maxTokens) {
clientOptions.maxTokens = 75;
} else if (/\b(o\d)\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
const shouldRemoveMaxTokens = /\b(o\d|gpt-[5-9])\b/i.test(clientOptions.model);
if (shouldRemoveMaxTokens && clientOptions.maxTokens != null) {
delete clientOptions.maxTokens;
} else if (!shouldRemoveMaxTokens && !clientOptions.maxTokens) {
clientOptions.maxTokens = 75;
}
if (shouldRemoveMaxTokens && clientOptions?.modelKwargs?.max_completion_tokens != null) {
delete clientOptions.modelKwargs.max_completion_tokens;
} else if (shouldRemoveMaxTokens && clientOptions?.modelKwargs?.max_output_tokens != null) {
delete clientOptions.modelKwargs.max_output_tokens;
}
clientOptions = Object.assign(

View File

@@ -728,6 +728,239 @@ describe('AgentClient - titleConvo', () => {
});
});
describe('getOptions method - GPT-5+ model handling', () => {
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
beforeEach(() => {
jest.clearAllMocks();
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: {
model: 'gpt-5',
},
};
mockReq = {
app: {
locals: {},
},
user: {
id: 'user-123',
},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
};
client = new AgentClient(mockOptions);
});
it('should move maxTokens to modelKwargs.max_completion_tokens for GPT-5 models', () => {
const clientOptions = {
model: 'gpt-5',
maxTokens: 2048,
temperature: 0.7,
};
// Simulate the getOptions logic that handles GPT-5+ models
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs).toBeDefined();
expect(clientOptions.modelKwargs.max_completion_tokens).toBe(2048);
expect(clientOptions.temperature).toBe(0.7); // Other options should remain
});
it('should move maxTokens to modelKwargs.max_output_tokens for GPT-5 models with useResponsesApi', () => {
const clientOptions = {
model: 'gpt-5',
maxTokens: 2048,
temperature: 0.7,
useResponsesApi: true,
};
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
const paramName =
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
clientOptions.modelKwargs[paramName] = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs).toBeDefined();
expect(clientOptions.modelKwargs.max_output_tokens).toBe(2048);
expect(clientOptions.temperature).toBe(0.7); // Other options should remain
});
it('should handle GPT-5+ models with existing modelKwargs', () => {
const clientOptions = {
model: 'gpt-6',
maxTokens: 1500,
temperature: 0.8,
modelKwargs: {
customParam: 'value',
},
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs).toEqual({
customParam: 'value',
max_completion_tokens: 1500,
});
});
it('should not modify maxTokens for non-GPT-5+ models', () => {
const clientOptions = {
model: 'gpt-4',
maxTokens: 2048,
temperature: 0.7,
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
// Should not be modified since it's GPT-4
expect(clientOptions.maxTokens).toBe(2048);
expect(clientOptions.modelKwargs).toBeUndefined();
});
it('should handle various GPT-5+ model formats', () => {
const testCases = [
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
{ model: 'gpt-7-preview', shouldTransform: true },
{ model: 'gpt-8', shouldTransform: true },
{ model: 'gpt-9-mini', shouldTransform: true },
{ model: 'gpt-4', shouldTransform: false },
{ model: 'gpt-4o', shouldTransform: false },
{ model: 'gpt-3.5-turbo', shouldTransform: false },
{ model: 'claude-3', shouldTransform: false },
];
testCases.forEach(({ model, shouldTransform }) => {
const clientOptions = {
model,
maxTokens: 1000,
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
if (shouldTransform) {
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(1000);
} else {
expect(clientOptions.maxTokens).toBe(1000);
expect(clientOptions.modelKwargs).toBeUndefined();
}
});
});
it('should not swap max token param for older models when using useResponsesApi', () => {
const testCases = [
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
{ model: 'gpt-7-preview', shouldTransform: true },
{ model: 'gpt-8', shouldTransform: true },
{ model: 'gpt-9-mini', shouldTransform: true },
{ model: 'gpt-4', shouldTransform: false },
{ model: 'gpt-4o', shouldTransform: false },
{ model: 'gpt-3.5-turbo', shouldTransform: false },
{ model: 'claude-3', shouldTransform: false },
];
testCases.forEach(({ model, shouldTransform }) => {
const clientOptions = {
model,
maxTokens: 1000,
useResponsesApi: true,
};
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
const paramName =
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
clientOptions.modelKwargs[paramName] = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
if (shouldTransform) {
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs?.max_output_tokens).toBe(1000);
} else {
expect(clientOptions.maxTokens).toBe(1000);
expect(clientOptions.modelKwargs).toBeUndefined();
}
});
});
it('should not transform if maxTokens is null or undefined', () => {
const testCases = [
{ model: 'gpt-5', maxTokens: null },
{ model: 'gpt-5', maxTokens: undefined },
{ model: 'gpt-6', maxTokens: 0 }, // Should transform even if 0
];
testCases.forEach(({ model, maxTokens }, index) => {
const clientOptions = {
model,
maxTokens,
temperature: 0.7,
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
if (index < 2) {
// null or undefined cases
expect(clientOptions.maxTokens).toBe(maxTokens);
expect(clientOptions.modelKwargs).toBeUndefined();
} else {
// 0 case - should transform
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(0);
}
});
});
});
describe('runMemory method', () => {
let client;
let mockReq;

View File

@@ -233,6 +233,26 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
);
}
}
// Edge case: sendMessage completed but abort happened during sendCompletion
// We need to ensure a final event is sent
else if (!res.headersSent && !res.finished) {
logger.debug(
'[AgentController] Handling edge case: `sendMessage` completed but aborted during `sendCompletion`',
);
const finalResponse = { ...response };
finalResponse.error = true;
sendEvent(res, {
final: true,
conversation,
title: conversation.title,
requestMessage: userMessage,
responseMessage: finalResponse,
error: { message: 'Request was aborted during completion' },
});
res.end();
}
// Save user message if needed
if (!client.skipSaveUserMessage) {

View File

@@ -194,6 +194,9 @@ const updateAgentHandler = async (req, res) => {
});
}
// Add version count to the response
updatedAgent.version = updatedAgent.versions ? updatedAgent.versions.length : 0;
if (updatedAgent.author) {
updatedAgent.author = updatedAgent.author.toString();
}

View File

@@ -498,6 +498,28 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Agent not found' });
});
test('should include version field in update response', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Updated with Version Check',
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
// Verify version field is included and is a number
expect(updatedAgent).toHaveProperty('version');
expect(typeof updatedAgent.version).toBe('number');
expect(updatedAgent.version).toBeGreaterThanOrEqual(1);
// Verify in database
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(updatedAgent.version).toBe(agentInDb.versions.length);
});
test('should handle validation errors properly', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;

View File

@@ -8,14 +8,12 @@ const express = require('express');
const passport = require('passport');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const { isEnabled, ErrorController } = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const validateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const errorController = require('./controllers/ErrorController');
const initializeMCPs = require('./services/initializeMCPs');
const configureSocialLogins = require('./socialLogins');
const AppService = require('./services/AppService');
@@ -120,8 +118,7 @@ const startServer = async () => {
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
// Add the error controller one more time after all routes
app.use(errorController);
app.use(ErrorController);
app.use((req, res) => {
res.set({

View File

@@ -92,7 +92,7 @@ async function healthCheckPoll(app, retries = 0) {
if (response.status === 200) {
return; // App is healthy
}
} catch (error) {
} catch {
// Ignore connection errors during polling
}

View File

@@ -33,7 +33,7 @@ const validateModel = async (req, res, next) => {
return next();
}
const { ILLEGAL_MODEL_REQ_SCORE: score = 5 } = process.env ?? {};
const { ILLEGAL_MODEL_REQ_SCORE: score = 1 } = process.env ?? {};
const type = ViolationTypes.ILLEGAL_MODEL_REQUEST;
const errorMessage = {

View File

@@ -413,13 +413,15 @@ router.post('/', async (req, res) => {
logger.error('[/files] Error deleting file:', error);
}
res.status(500).json({ message });
}
if (cleanup) {
try {
await fs.unlink(req.file.path);
} catch (error) {
logger.error('[/files] Error deleting file after file processing:', error);
} finally {
if (cleanup) {
try {
await fs.unlink(req.file.path);
} catch (error) {
logger.error('[/files] Error deleting file after file processing:', error);
}
} else {
logger.debug('[/files] File processing completed without cleanup');
}
}
});

View File

@@ -13,6 +13,8 @@ const { getRoleByName } = require('~/models/Role');
const router = express.Router();
const memoryPayloadLimit = express.json({ limit: '100kb' });
const checkMemoryRead = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.READ],
@@ -60,6 +62,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
const memoryConfig = req.app.locals?.memory;
const tokenLimit = memoryConfig?.tokenLimit;
const charLimit = memoryConfig?.charLimit || 10000;
let usagePercentage = null;
if (tokenLimit && tokenLimit > 0) {
@@ -70,6 +73,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
memories: sortedMemories,
totalTokens,
tokenLimit: tokenLimit || null,
charLimit,
usagePercentage,
});
} catch (error) {
@@ -83,7 +87,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
* Body: { key: string, value: string }
* Returns 201 and { created: true, memory: <createdDoc> } when successful.
*/
router.post('/', checkMemoryCreate, async (req, res) => {
router.post('/', memoryPayloadLimit, checkMemoryCreate, async (req, res) => {
const { key, value } = req.body;
if (typeof key !== 'string' || key.trim() === '') {
@@ -94,13 +98,25 @@ router.post('/', checkMemoryCreate, async (req, res) => {
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
}
const memoryConfig = req.app.locals?.memory;
const charLimit = memoryConfig?.charLimit || 10000;
if (key.length > 1000) {
return res.status(400).json({
error: `Key exceeds maximum length of 1000 characters. Current length: ${key.length} characters.`,
});
}
if (value.length > charLimit) {
return res.status(400).json({
error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
});
}
try {
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
const memories = await getAllUserMemories(req.user.id);
// Check token limit
const memoryConfig = req.app.locals?.memory;
const tokenLimit = memoryConfig?.tokenLimit;
if (tokenLimit) {
@@ -175,7 +191,7 @@ router.patch('/preferences', checkMemoryOptOut, async (req, res) => {
* Body: { key?: string, value: string }
* Returns 200 and { updated: true, memory: <updatedDoc> } when successful.
*/
router.patch('/:key', checkMemoryUpdate, async (req, res) => {
router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, async (req, res) => {
const { key: urlKey } = req.params;
const { key: bodyKey, value } = req.body || {};
@@ -183,9 +199,23 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
}
// Use the key from the body if provided, otherwise use the key from the URL
const newKey = bodyKey || urlKey;
const memoryConfig = req.app.locals?.memory;
const charLimit = memoryConfig?.charLimit || 10000;
if (newKey.length > 1000) {
return res.status(400).json({
error: `Key exceeds maximum length of 1000 characters. Current length: ${newKey.length} characters.`,
});
}
if (value.length > charLimit) {
return res.status(400).json({
error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
});
}
try {
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
@@ -196,7 +226,6 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
return res.status(404).json({ error: 'Memory not found.' });
}
// If the key is changing, we need to handle it specially
if (newKey !== urlKey) {
const keyExists = memories.find((m) => m.key === newKey);
if (keyExists) {
@@ -219,7 +248,6 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
return res.status(500).json({ error: 'Failed to delete old memory.' });
}
} else {
// Key is not changing, just update the value
const result = await setMemory({
userId: req.user.id,
key: newKey,

View File

@@ -1,7 +1,10 @@
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
const express = require('express');
const passport = require('passport');
const { isEnabled } = require('@librechat/api');
const { randomState } = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const {
checkBan,
logHeaders,
@@ -10,8 +13,6 @@ const {
checkDomainAllowed,
} = require('~/server/middleware');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const router = express.Router();
@@ -46,13 +47,13 @@ const oauthHandler = async (req, res) => {
};
router.get('/error', (req, res) => {
// A single error message is pushed by passport when authentication fails.
/** A single error message is pushed by passport when authentication fails. */
const errorMessage = req.session?.messages?.pop() || 'Unknown error';
logger.error('Error in OAuth authentication:', {
message: req.session?.messages?.pop() || 'Unknown error',
message: errorMessage,
});
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
res.redirect(`${domains.client}/login?redirect=false`);
res.redirect(`${domains.client}/login?redirect=false&error=${ErrorTypes.AUTH_FAILED}`);
});
/**

View File

@@ -1,9 +1,8 @@
const { agentsConfigSetup, loadWebSearchConfig } = require('@librechat/api');
const { loadMemoryConfig, agentsConfigSetup, loadWebSearchConfig } = require('@librechat/api');
const {
FileSources,
loadOCRConfig,
EModelEndpoint,
loadMemoryConfig,
getConfigDefaults,
} = require('librechat-data-provider');
const {

View File

@@ -60,7 +60,14 @@ const replaceArtifactContent = (originalText, artifact, original, updated) => {
// Find boundaries between ARTIFACT_START and ARTIFACT_END
const contentStart = artifactContent.indexOf('\n', artifactContent.indexOf(ARTIFACT_START)) + 1;
const contentEnd = artifactContent.lastIndexOf(ARTIFACT_END);
let contentEnd = artifactContent.lastIndexOf(ARTIFACT_END);
// Special case: if contentEnd is 0, it means the only ::: found is at the start of :::artifact
// This indicates an incomplete artifact (no closing :::)
// We need to check that it's exactly at position 0 (the beginning of artifactContent)
if (contentEnd === 0 && artifactContent.indexOf(ARTIFACT_START) === 0) {
contentEnd = artifactContent.length;
}
if (contentStart === -1 || contentEnd === -1) {
return null;
@@ -72,12 +79,20 @@ const replaceArtifactContent = (originalText, artifact, original, updated) => {
// Determine where to look for the original content
let searchStart, searchEnd;
if (codeBlockStart !== -1 && codeBlockEnd !== -1) {
// If code blocks exist, search between them
if (codeBlockStart !== -1) {
// Code block starts
searchStart = codeBlockStart + 4; // after ```\n
searchEnd = codeBlockEnd;
if (codeBlockEnd !== -1 && codeBlockEnd > codeBlockStart) {
// Code block has proper ending
searchEnd = codeBlockEnd;
} else {
// No closing backticks found or they're before the opening (shouldn't happen)
// This might be an incomplete artifact - search to contentEnd
searchEnd = contentEnd;
}
} else {
// Otherwise search in the whole artifact content
// No code blocks at all
searchStart = contentStart;
searchEnd = contentEnd;
}

View File

@@ -89,9 +89,9 @@ describe('replaceArtifactContent', () => {
};
test('should replace content within artifact boundaries', () => {
const original = 'console.log(\'hello\')';
const original = "console.log('hello')";
const artifact = createTestArtifact(original);
const updated = 'console.log(\'updated\')';
const updated = "console.log('updated')";
const result = replaceArtifactContent(artifact.text, artifact, original, updated);
expect(result).toContain(updated);
@@ -317,4 +317,182 @@ console.log(greeting);`;
expect(result).not.toContain('\n\n```');
expect(result).not.toContain('```\n\n');
});
describe('incomplete artifacts', () => {
test('should handle incomplete artifacts (missing closing ::: and ```)', () => {
const original = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pomodoro</title>
<meta name="description" content="A single-file Pomodoro timer with logs, charts, sounds, and dark mode." />
<style>
:root{`;
const prefix = `Awesome idea! I'll deliver a complete single-file HTML app called "Pomodoro" with:
- Custom session/break durations
You can save this as pomodoro.html and open it directly in your browser.
`;
// This simulates the real incomplete artifact case - no closing ``` or :::
const incompleteArtifact = `${ARTIFACT_START}{identifier="pomodoro-single-file-app" type="text/html" title="Pomodoro — Single File App"}
\`\`\`
${original}`;
const fullText = prefix + incompleteArtifact;
const message = { text: fullText };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
expect(artifacts[0].end).toBe(fullText.length);
const updated = original.replace('Pomodoro</title>', 'Pomodoro</title>UPDATED');
const result = replaceArtifactContent(fullText, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain('UPDATED');
expect(result).toContain(prefix);
// Should not have added closing markers
expect(result).not.toMatch(/:::\s*$/);
});
test('should handle incomplete artifacts with only opening code block', () => {
const original = 'function hello() { console.log("world"); }';
const incompleteArtifact = `${ARTIFACT_START}{id="test"}\n\`\`\`\n${original}`;
const message = { text: incompleteArtifact };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
const updated = 'function hello() { console.log("UPDATED"); }';
const result = replaceArtifactContent(incompleteArtifact, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain('UPDATED');
});
test('should handle incomplete artifacts without code blocks', () => {
const original = 'Some plain text content';
const incompleteArtifact = `${ARTIFACT_START}{id="test"}\n${original}`;
const message = { text: incompleteArtifact };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
const updated = 'Some UPDATED text content';
const result = replaceArtifactContent(incompleteArtifact, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain('UPDATED');
});
});
describe('regression tests for edge cases', () => {
test('should still handle complete artifacts correctly', () => {
// Ensure we didn't break normal artifact handling
const original = 'console.log("test");';
const artifact = createArtifactText({ content: original });
const message = { text: artifact };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
const updated = 'console.log("updated");';
const result = replaceArtifactContent(artifact, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain(updated);
expect(result).toContain(ARTIFACT_END);
expect(result).toMatch(/```\nconsole\.log\("updated"\);\n```/);
});
test('should handle multiple complete artifacts', () => {
// Ensure multiple artifacts still work
const content1 = 'First artifact';
const content2 = 'Second artifact';
const text = `${createArtifactText({ content: content1 })}\n\n${createArtifactText({ content: content2 })}`;
const message = { text };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(2);
// Update first artifact
const result1 = replaceArtifactContent(text, artifacts[0], content1, 'First UPDATED');
expect(result1).not.toBeNull();
expect(result1).toContain('First UPDATED');
expect(result1).toContain(content2);
// Update second artifact
const result2 = replaceArtifactContent(text, artifacts[1], content2, 'Second UPDATED');
expect(result2).not.toBeNull();
expect(result2).toContain(content1);
expect(result2).toContain('Second UPDATED');
});
test('should not mistake ::: at position 0 for artifact end in complete artifacts', () => {
// This tests the specific fix - ensuring contentEnd=0 doesn't break complete artifacts
const original = 'test content';
// Create an artifact that will have ::: at position 0 when substring'd
const artifact = `${ARTIFACT_START}\n\`\`\`\n${original}\n\`\`\`\n${ARTIFACT_END}`;
const message = { text: artifact };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
const updated = 'updated content';
const result = replaceArtifactContent(artifact, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain(updated);
expect(result).toContain(ARTIFACT_END);
});
test('should handle empty artifacts', () => {
// Edge case: empty artifact
const artifact = `${ARTIFACT_START}\n${ARTIFACT_END}`;
const message = { text: artifact };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
// Trying to replace non-existent content should return null
const result = replaceArtifactContent(artifact, artifacts[0], 'something', 'updated');
expect(result).toBeNull();
});
test('should preserve whitespace and formatting in complete artifacts', () => {
const original = ` function test() {
return {
value: 42
};
}`;
const artifact = createArtifactText({ content: original });
const message = { text: artifact };
const artifacts = findAllArtifacts(message);
const updated = ` function test() {
return {
value: 100
};
}`;
const result = replaceArtifactContent(artifact, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain('value: 100');
// Should preserve exact formatting
expect(result).toMatch(
/```\n {2}function test\(\) \{\n {4}return \{\n {6}value: 100\n {4}\};\n {2}\}\n```/,
);
});
});
});

View File

@@ -1,12 +1,11 @@
const { logger } = require('@librechat/data-schemas');
const { EModelEndpoint } = require('librechat-data-provider');
const { useAzurePlugins } = require('~/server/services/Config/EndpointService').config;
const {
getAnthropicModels,
getBedrockModels,
getOpenAIModels,
getGoogleModels,
getBedrockModels,
getAnthropicModels,
} = require('~/server/services/ModelService');
const { logger } = require('~/config');
/**
* Loads the default models for the application.
@@ -16,58 +15,42 @@ const { logger } = require('~/config');
*/
async function loadDefaultModels(req) {
try {
const [
openAI,
anthropic,
azureOpenAI,
gptPlugins,
assistants,
azureAssistants,
google,
bedrock,
] = await Promise.all([
getOpenAIModels({ user: req.user.id }).catch((error) => {
logger.error('Error fetching OpenAI models:', error);
return [];
}),
getAnthropicModels({ user: req.user.id }).catch((error) => {
logger.error('Error fetching Anthropic models:', error);
return [];
}),
getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => {
logger.error('Error fetching Azure OpenAI models:', error);
return [];
}),
getOpenAIModels({ user: req.user.id, azure: useAzurePlugins, plugins: true }).catch(
(error) => {
logger.error('Error fetching Plugin models:', error);
const [openAI, anthropic, azureOpenAI, assistants, azureAssistants, google, bedrock] =
await Promise.all([
getOpenAIModels({ user: req.user.id }).catch((error) => {
logger.error('Error fetching OpenAI models:', error);
return [];
},
),
getOpenAIModels({ assistants: true }).catch((error) => {
logger.error('Error fetching OpenAI Assistants API models:', error);
return [];
}),
getOpenAIModels({ azureAssistants: true }).catch((error) => {
logger.error('Error fetching Azure OpenAI Assistants API models:', error);
return [];
}),
Promise.resolve(getGoogleModels()).catch((error) => {
logger.error('Error getting Google models:', error);
return [];
}),
Promise.resolve(getBedrockModels()).catch((error) => {
logger.error('Error getting Bedrock models:', error);
return [];
}),
]);
}),
getAnthropicModels({ user: req.user.id }).catch((error) => {
logger.error('Error fetching Anthropic models:', error);
return [];
}),
getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => {
logger.error('Error fetching Azure OpenAI models:', error);
return [];
}),
getOpenAIModels({ assistants: true }).catch((error) => {
logger.error('Error fetching OpenAI Assistants API models:', error);
return [];
}),
getOpenAIModels({ azureAssistants: true }).catch((error) => {
logger.error('Error fetching Azure OpenAI Assistants API models:', error);
return [];
}),
Promise.resolve(getGoogleModels()).catch((error) => {
logger.error('Error getting Google models:', error);
return [];
}),
Promise.resolve(getBedrockModels()).catch((error) => {
logger.error('Error getting Bedrock models:', error);
return [];
}),
]);
return {
[EModelEndpoint.openAI]: openAI,
[EModelEndpoint.agents]: openAI,
[EModelEndpoint.google]: google,
[EModelEndpoint.anthropic]: anthropic,
[EModelEndpoint.gptPlugins]: gptPlugins,
[EModelEndpoint.azureOpenAI]: azureOpenAI,
[EModelEndpoint.assistants]: assistants,
[EModelEndpoint.azureAssistants]: azureAssistants,

View File

@@ -1,6 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-data-provider');
const { loadAgent } = require('~/models/Agent');
const { logger } = require('~/config');
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody;

View File

@@ -1,4 +1,5 @@
const { logger } = require('@librechat/data-schemas');
const { validateAgentModel } = require('@librechat/api');
const { createContentAggregator } = require('@librechat/agents');
const {
Constants,
@@ -11,10 +12,12 @@ const {
getDefaultHandlers,
} = require('~/server/controllers/agents/callbacks');
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { getCustomEndpointConfig } = require('~/server/services/Config');
const { loadAgentTools } = require('~/server/services/ToolService');
const AgentClient = require('~/server/controllers/agents/client');
const { getAgent } = require('~/models/Agent');
const { logViolation } = require('~/cache');
function createToolLoader() {
/**
@@ -72,6 +75,19 @@ const initializeClient = async ({ req, res, endpointOption }) => {
throw new Error('Agent not found');
}
const modelsConfig = await getModelsConfig(req);
const validationResult = await validateAgentModel({
req,
res,
modelsConfig,
logViolation,
agent: primaryAgent,
});
if (!validationResult.isValid) {
throw new Error(validationResult.error?.message);
}
const agentConfigs = new Map();
/** @type {Set<string>} */
const allowedProviders = new Set(req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders);
@@ -101,6 +117,19 @@ const initializeClient = async ({ req, res, endpointOption }) => {
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
const validationResult = await validateAgentModel({
req,
res,
agent,
modelsConfig,
logViolation,
});
if (!validationResult.isValid) {
throw new Error(validationResult.error?.message);
}
const config = await initializeAgent({
req,
res,

View File

@@ -33,7 +33,7 @@ async function withTimeout(promise, timeoutMs, timeoutMessage) {
* @param {string} [params.body.model] - Optional. The ID of the model to be used for this run.
* @param {string} [params.body.instructions] - Optional. Override the default system message of the assistant.
* @param {string} [params.body.additional_instructions] - Optional. Appends additional instructions
* at theend of the instructions for the run. This is useful for modifying
* at the end of the instructions for the run. This is useful for modifying
* the behavior on a per-run basis without overriding other instructions.
* @param {Object[]} [params.body.tools] - Optional. Override the tools the assistant can use for this run.
* @param {string[]} [params.body.file_ids] - Optional.

View File

@@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const { sleep } = require('@librechat/agents');
const { getToolkitKey } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { zodToJsonSchema } = require('zod-to-json-schema');
const { Calculator } = require('@langchain/community/tools/calculator');
@@ -11,7 +12,6 @@ const {
ErrorTypes,
ContentTypes,
imageGenTools,
EToolResources,
EModelEndpoint,
actionDelimiter,
ImageVisionTool,
@@ -40,30 +40,6 @@ const { recordUsage } = require('~/server/services/Threads');
const { loadTools } = require('~/app/clients/tools/util');
const { redactMessage } = require('~/config/parsers');
/**
* @param {string} toolName
* @returns {string | undefined} toolKey
*/
function getToolkitKey(toolName) {
/** @type {string|undefined} */
let toolkitKey;
for (const toolkit of toolkits) {
if (toolName.startsWith(EToolResources.image_edit)) {
const splitMatches = toolkit.pluginKey.split('_');
const suffix = splitMatches[splitMatches.length - 1];
if (toolName.endsWith(suffix)) {
toolkitKey = toolkit.pluginKey;
break;
}
}
if (toolName.startsWith(toolkit.pluginKey)) {
toolkitKey = toolkit.pluginKey;
break;
}
}
return toolkitKey;
}
/**
* Loads and formats tools from the specified tool directory.
*
@@ -145,7 +121,7 @@ function loadAndFormatTools({ directory, adminFilter = [], adminIncluded = [] })
for (const toolInstance of basicToolInstances) {
const formattedTool = formatToOpenAIAssistantTool(toolInstance);
let toolName = formattedTool[Tools.function].name;
toolName = getToolkitKey(toolName) ?? toolName;
toolName = getToolkitKey({ toolkits, toolName }) ?? toolName;
if (filter.has(toolName) && included.size === 0) {
continue;
}

View File

@@ -2,11 +2,11 @@ const {
SystemRoles,
Permissions,
PermissionTypes,
isMemoryEnabled,
removeNullishValues,
} = require('librechat-data-provider');
const { logger } = require('@librechat/data-schemas');
const { isMemoryEnabled } = require('@librechat/api');
const { updateAccessPermissions } = require('~/models/Role');
const { logger } = require('~/config');
/**
* Loads the default interface object.

View File

@@ -1,10 +1,10 @@
const fs = require('fs');
const { isEnabled } = require('@librechat/api');
const LdapStrategy = require('passport-ldapauth');
const { SystemRoles } = require('librechat-data-provider');
const { logger } = require('@librechat/data-schemas');
const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
const { createUser, findUser, updateUser, countUsers } = require('~/models');
const { getBalanceConfig } = require('~/server/services/Config');
const { isEnabled } = require('~/server/utils');
const {
LDAP_URL,
@@ -90,6 +90,14 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
let user = await findUser({ ldapId });
if (user && user.provider !== 'ldap') {
logger.info(
`[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`,
);
return done(null, false, {
message: ErrorTypes.AUTH_FAILED,
});
}
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
const fullName =

View File

@@ -3,9 +3,9 @@ const fetch = require('node-fetch');
const passport = require('passport');
const client = require('openid-client');
const jwtDecode = require('jsonwebtoken/decode');
const { CacheKeys } = require('librechat-data-provider');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { hashToken, logger } = require('@librechat/data-schemas');
const { CacheKeys, ErrorTypes } = require('librechat-data-provider');
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
const { isEnabled, safeStringify, logHeaders } = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@@ -320,6 +320,14 @@ async function setupOpenId() {
} for openidId: ${claims.sub}`,
);
}
if (user != null && user.provider !== 'openid') {
logger.info(
`[openidStrategy] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`,
);
return done(null, false, {
message: ErrorTypes.AUTH_FAILED,
});
}
const userinfo = {
...claims,
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),

View File

@@ -1,7 +1,8 @@
const fetch = require('node-fetch');
const jwtDecode = require('jsonwebtoken/decode');
const { setupOpenId } = require('./openidStrategy');
const { ErrorTypes } = require('librechat-data-provider');
const { findUser, createUser, updateUser } = require('~/models');
const { setupOpenId } = require('./openidStrategy');
// --- Mocks ---
jest.mock('node-fetch');
@@ -50,7 +51,7 @@ jest.mock('openid-client', () => {
issuer: 'https://fake-issuer.com',
// Add any other properties needed by the implementation
}),
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
fetchUserInfo: jest.fn().mockImplementation(() => {
// Only return additional properties, but don't override any claims
return Promise.resolve({});
}),
@@ -261,17 +262,20 @@ describe('setupOpenId', () => {
});
it('should update an existing user on login', async () => {
// Arrange simulate that a user already exists
// Arrange simulate that a user already exists with openid provider
const existingUser = {
_id: 'existingUserId',
provider: 'local',
provider: 'openid',
email: tokenset.claims().email,
openidId: '',
username: '',
name: '',
};
findUser.mockImplementation(async (query) => {
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
if (
query.openidId === tokenset.claims().sub ||
(query.email === tokenset.claims().email && query.provider === 'openid')
) {
return existingUser;
}
return null;
@@ -294,12 +298,38 @@ describe('setupOpenId', () => {
);
});
it('should block login when email exists with different provider', async () => {
// Arrange simulate that a user exists with same email but different provider
const existingUser = {
_id: 'existingUserId',
provider: 'google',
email: tokenset.claims().email,
googleId: 'some-google-id',
username: 'existinguser',
name: 'Existing User',
};
findUser.mockImplementation(async (query) => {
if (query.email === tokenset.claims().email && !query.provider) {
return existingUser;
}
return null;
});
// Act
const result = await validate(tokenset);
// Assert verify that the strategy rejects login
expect(result.user).toBe(false);
expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED);
expect(createUser).not.toHaveBeenCalled();
expect(updateUser).not.toHaveBeenCalled();
});
it('should enforce the required role and reject login if missing', async () => {
// Arrange simulate a token without the required role.
jwtDecode.mockReturnValue({
roles: ['SomeOtherRole'],
});
const userinfo = tokenset.claims();
// Act
const { user, details } = await validate(tokenset);
@@ -310,9 +340,6 @@ describe('setupOpenId', () => {
});
it('should attempt to download and save the avatar if picture is provided', async () => {
// Arrange ensure userinfo contains a picture URL
const userinfo = tokenset.claims();
// Act
const { user } = await validate(tokenset);

View File

@@ -22,9 +22,12 @@ const handleExistingUser = async (oldUser, avatarUrl) => {
const isLocal = fileStrategy === FileSources.local;
let updatedAvatar = false;
if (isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
const hasManualFlag =
typeof oldUser?.avatar === 'string' && oldUser.avatar.includes('?manual=true');
if (isLocal && (!oldUser?.avatar || !hasManualFlag)) {
updatedAvatar = avatarUrl;
} else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
} else if (!isLocal && (!oldUser?.avatar || !hasManualFlag)) {
const userId = oldUser._id;
const resizedBuffer = await resizeAvatar({
userId,

View File

@@ -0,0 +1,164 @@
const { FileSources } = require('librechat-data-provider');
const { handleExistingUser } = require('./process');
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
jest.mock('~/server/services/Files/images/avatar', () => ({
resizeAvatar: jest.fn(),
}));
jest.mock('~/models', () => ({
updateUser: jest.fn(),
createUser: jest.fn(),
getUserById: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getBalanceConfig: jest.fn(),
}));
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { updateUser } = require('~/models');
describe('handleExistingUser', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.CDN_PROVIDER = FileSources.local;
});
it('should handle null avatar without throwing error', async () => {
const oldUser = {
_id: 'user123',
avatar: null,
};
const avatarUrl = 'https://example.com/avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
});
it('should handle undefined avatar without throwing error', async () => {
const oldUser = {
_id: 'user123',
// avatar is undefined
};
const avatarUrl = 'https://example.com/avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
});
it('should not update avatar if it has manual=true flag', async () => {
const oldUser = {
_id: 'user123',
avatar: 'https://example.com/avatar.png?manual=true',
};
const avatarUrl = 'https://example.com/new-avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).not.toHaveBeenCalled();
});
it('should update avatar for local storage when avatar has no manual flag', async () => {
const oldUser = {
_id: 'user123',
avatar: 'https://example.com/old-avatar.png',
};
const avatarUrl = 'https://example.com/new-avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
});
it('should process avatar for non-local storage', async () => {
process.env.CDN_PROVIDER = 's3';
const mockProcessAvatar = jest.fn().mockResolvedValue('processed-avatar-url');
getStrategyFunctions.mockReturnValue({ processAvatar: mockProcessAvatar });
resizeAvatar.mockResolvedValue(Buffer.from('resized-image'));
const oldUser = {
_id: 'user123',
avatar: null,
};
const avatarUrl = 'https://example.com/avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(resizeAvatar).toHaveBeenCalledWith({
userId: 'user123',
input: avatarUrl,
});
expect(mockProcessAvatar).toHaveBeenCalledWith({
buffer: Buffer.from('resized-image'),
userId: 'user123',
manual: 'false',
});
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: 'processed-avatar-url' });
});
it('should not update if avatar already has manual flag in non-local storage', async () => {
process.env.CDN_PROVIDER = 's3';
const oldUser = {
_id: 'user123',
avatar: 'https://cdn.example.com/avatar.png?manual=true',
};
const avatarUrl = 'https://example.com/new-avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(resizeAvatar).not.toHaveBeenCalled();
expect(updateUser).not.toHaveBeenCalled();
});
it('should handle avatar with query parameters but without manual flag', async () => {
const oldUser = {
_id: 'user123',
avatar: 'https://example.com/avatar.png?size=large&format=webp',
};
const avatarUrl = 'https://example.com/new-avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
});
it('should handle empty string avatar', async () => {
const oldUser = {
_id: 'user123',
avatar: '',
};
const avatarUrl = 'https://example.com/avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
});
it('should handle avatar with manual=false parameter', async () => {
const oldUser = {
_id: 'user123',
avatar: 'https://example.com/avatar.png?manual=false',
};
const avatarUrl = 'https://example.com/new-avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
});
it('should handle oldUser being null gracefully', async () => {
const avatarUrl = 'https://example.com/avatar.png';
// This should throw an error when trying to access oldUser._id
await expect(handleExistingUser(null, avatarUrl)).rejects.toThrow();
});
});

View File

@@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const passport = require('passport');
const { ErrorTypes } = require('librechat-data-provider');
const { hashToken, logger } = require('@librechat/data-schemas');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@@ -203,6 +204,15 @@ async function setupSaml() {
);
}
if (user && user.provider !== 'saml') {
logger.info(
`[samlStrategy] User ${user.email} already exists with provider ${user.provider}`,
);
return done(null, false, {
message: ErrorTypes.AUTH_FAILED,
});
}
const fullName = getFullName(profile);
const username = convertToUsername(

View File

@@ -378,11 +378,11 @@ u7wlOSk+oFzDIO/UILIA
});
it('should update an existing user on login', async () => {
// Set up findUser to return an existing user
// Set up findUser to return an existing user with saml provider
const { findUser } = require('~/models');
const existingUser = {
_id: 'existing-user-id',
provider: 'local',
provider: 'saml',
email: baseProfile.email,
samlId: '',
username: 'oldusername',
@@ -400,6 +400,26 @@ u7wlOSk+oFzDIO/UILIA
expect(user.email).toBe(baseProfile.email);
});
it('should block login when email exists with different provider', async () => {
// Set up findUser to return a user with different provider
const { findUser } = require('~/models');
const existingUser = {
_id: 'existing-user-id',
provider: 'google',
email: baseProfile.email,
googleId: 'some-google-id',
username: 'existinguser',
name: 'Existing User',
};
findUser.mockResolvedValue(existingUser);
const profile = { ...baseProfile };
const result = await validate(profile);
expect(result.user).toBe(false);
expect(result.details.message).toBe(require('librechat-data-provider').ErrorTypes.AUTH_FAILED);
});
it('should attempt to download and save the avatar if picture is provided', async () => {
const profile = { ...baseProfile };

View File

@@ -1,6 +1,7 @@
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const { createSocialUser, handleExistingUser } = require('./process');
const { isEnabled } = require('~/server/utils');
const { findUser } = require('~/models');
const socialLogin =
@@ -11,12 +12,20 @@ const socialLogin =
profile,
});
const oldUser = await findUser({ email: email.trim() });
const existingUser = await findUser({ email: email.trim() });
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
if (oldUser) {
await handleExistingUser(oldUser, avatarUrl);
return cb(null, oldUser);
if (existingUser?.provider === provider) {
await handleExistingUser(existingUser, avatarUrl);
return cb(null, existingUser);
} else if (existingUser) {
logger.info(
`[${provider}Login] User ${email} already exists with provider ${existingUser.provider}`,
);
const error = new Error(ErrorTypes.AUTH_FAILED);
error.code = ErrorTypes.AUTH_FAILED;
error.provider = existingUser.provider;
return cb(error);
}
if (ALLOW_SOCIAL_REGISTRATION) {

View File

@@ -1370,7 +1370,7 @@
* @property {string} [model] - The model that the assistant used for this run.
* @property {string} [instructions] - The instructions that the assistant used for this run.
* @property {string} [additional_instructions] - Optional. Appends additional instructions
* at theend of the instructions for the run. This is useful for modifying
* at the end of the instructions for the run. This is useful for modifying
* @property {Tool[]} [tools] - The list of tools used for this run.
* @property {string[]} [file_ids] - The list of File IDs used for this run.
* @property {Object} [metadata] - Metadata associated with this run.

View File

@@ -19,6 +19,9 @@ const openAIModels = {
'gpt-4.1': 1047576,
'gpt-4.1-mini': 1047576,
'gpt-4.1-nano': 1047576,
'gpt-5': 400000,
'gpt-5-mini': 400000,
'gpt-5-nano': 400000,
'gpt-4o': 127500, // -500 from max
'gpt-4o-mini': 127500, // -500 from max
'gpt-4o-2024-05-13': 127500, // -500 from max
@@ -234,6 +237,9 @@ const aggregateModels = {
...xAIModels,
// misc.
kimi: 131000,
// GPT-OSS
'gpt-oss-20b': 131000,
'gpt-oss-120b': 131000,
};
const maxTokensMap = {
@@ -250,6 +256,11 @@ const modelMaxOutputs = {
o1: 32268, // -500 from max: 32,768
'o1-mini': 65136, // -500 from max: 65,536
'o1-preview': 32268, // -500 from max: 32,768
'gpt-5': 128000,
'gpt-5-mini': 128000,
'gpt-5-nano': 128000,
'gpt-oss-20b': 131000,
'gpt-oss-120b': 131000,
system_default: 1024,
};
@@ -468,10 +479,11 @@ const tiktokenModels = new Set([
]);
module.exports = {
tiktokenModels,
maxTokensMap,
inputSchema,
modelSchema,
maxTokensMap,
tiktokenModels,
maxOutputTokensMap,
matchModelName,
processModelData,
getModelMaxTokens,

View File

@@ -1,5 +1,11 @@
const { EModelEndpoint } = require('librechat-data-provider');
const { getModelMaxTokens, processModelData, matchModelName, maxTokensMap } = require('./tokens');
const {
maxOutputTokensMap,
getModelMaxTokens,
processModelData,
matchModelName,
maxTokensMap,
} = require('./tokens');
describe('getModelMaxTokens', () => {
test('should return correct tokens for exact match', () => {
@@ -150,6 +156,35 @@ describe('getModelMaxTokens', () => {
);
});
test('should return correct tokens for gpt-5 matches', () => {
expect(getModelMaxTokens('gpt-5')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5']);
expect(getModelMaxTokens('gpt-5-preview')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5']);
expect(getModelMaxTokens('openai/gpt-5')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5']);
expect(getModelMaxTokens('gpt-5-2025-01-30')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5'],
);
});
test('should return correct tokens for gpt-5-mini matches', () => {
expect(getModelMaxTokens('gpt-5-mini')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5-mini']);
expect(getModelMaxTokens('gpt-5-mini-preview')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-mini'],
);
expect(getModelMaxTokens('openai/gpt-5-mini')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-mini'],
);
});
test('should return correct tokens for gpt-5-nano matches', () => {
expect(getModelMaxTokens('gpt-5-nano')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5-nano']);
expect(getModelMaxTokens('gpt-5-nano-preview')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-nano'],
);
expect(getModelMaxTokens('openai/gpt-5-nano')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-nano'],
);
});
test('should return correct tokens for Anthropic models', () => {
const models = [
'claude-2.1',
@@ -349,6 +384,39 @@ describe('getModelMaxTokens', () => {
expect(getModelMaxTokens('o3')).toBe(o3Tokens);
expect(getModelMaxTokens('openai/o3')).toBe(o3Tokens);
});
test('should return correct tokens for GPT-OSS models', () => {
const expected = maxTokensMap[EModelEndpoint.openAI]['gpt-oss-20b'];
['gpt-oss-20b', 'gpt-oss-120b', 'openai/gpt-oss-20b', 'openai/gpt-oss-120b'].forEach((name) => {
expect(getModelMaxTokens(name)).toBe(expected);
});
});
test('should return correct max output tokens for GPT-5 models', () => {
const { getModelMaxOutputTokens } = require('./tokens');
['gpt-5', 'gpt-5-mini', 'gpt-5-nano'].forEach((model) => {
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
maxOutputTokensMap[EModelEndpoint.openAI][model],
);
expect(getModelMaxOutputTokens(model, EModelEndpoint.azureOpenAI)).toBe(
maxOutputTokensMap[EModelEndpoint.azureOpenAI][model],
);
});
});
test('should return correct max output tokens for GPT-OSS models', () => {
const { getModelMaxOutputTokens } = require('./tokens');
['gpt-oss-20b', 'gpt-oss-120b'].forEach((model) => {
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
maxOutputTokensMap[EModelEndpoint.openAI][model],
);
expect(getModelMaxOutputTokens(model, EModelEndpoint.azureOpenAI)).toBe(
maxOutputTokensMap[EModelEndpoint.azureOpenAI][model],
);
});
});
});
describe('matchModelName', () => {
@@ -420,6 +488,25 @@ describe('matchModelName', () => {
expect(matchModelName('gpt-4.1-nano-2024-08-06')).toBe('gpt-4.1-nano');
});
it('should return the closest matching key for gpt-5 matches', () => {
expect(matchModelName('openai/gpt-5')).toBe('gpt-5');
expect(matchModelName('gpt-5-preview')).toBe('gpt-5');
expect(matchModelName('gpt-5-2025-01-30')).toBe('gpt-5');
expect(matchModelName('gpt-5-2025-01-30-0130')).toBe('gpt-5');
});
it('should return the closest matching key for gpt-5-mini matches', () => {
expect(matchModelName('openai/gpt-5-mini')).toBe('gpt-5-mini');
expect(matchModelName('gpt-5-mini-preview')).toBe('gpt-5-mini');
expect(matchModelName('gpt-5-mini-2025-01-30')).toBe('gpt-5-mini');
});
it('should return the closest matching key for gpt-5-nano matches', () => {
expect(matchModelName('openai/gpt-5-nano')).toBe('gpt-5-nano');
expect(matchModelName('gpt-5-nano-preview')).toBe('gpt-5-nano');
expect(matchModelName('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano');
});
// Tests for Google models
it('should return the exact model name if it exists in maxTokensMap - Google models', () => {
expect(matchModelName('text-bison-32k', EModelEndpoint.google)).toBe('text-bison-32k');

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.8.0-rc1",
"version": "v0.8.0-rc2",
"description": "",
"type": "module",
"scripts": {

View File

@@ -0,0 +1,46 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { useChatContext } from './ChatContext';
import { getLatestText } from '~/utils';
interface ArtifactsContextValue {
isSubmitting: boolean;
latestMessageId: string | null;
latestMessageText: string;
conversationId: string | null;
}
const ArtifactsContext = createContext<ArtifactsContextValue | undefined>(undefined);
export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
const { isSubmitting, latestMessage, conversation } = useChatContext();
const latestMessageText = useMemo(() => {
return getLatestText({
messageId: latestMessage?.messageId ?? null,
text: latestMessage?.text ?? null,
content: latestMessage?.content ?? null,
} as TMessage);
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
/** Context value only created when relevant values change */
const contextValue = useMemo<ArtifactsContextValue>(
() => ({
isSubmitting,
latestMessageText,
latestMessageId: latestMessage?.messageId ?? null,
conversationId: conversation?.conversationId ?? null,
}),
[isSubmitting, latestMessage?.messageId, latestMessageText, conversation?.conversationId],
);
return <ArtifactsContext.Provider value={contextValue}>{children}</ArtifactsContext.Provider>;
}
export function useArtifactsContext() {
const context = useContext(ArtifactsContext);
if (!context) {
throw new Error('useArtifactsContext must be used within ArtifactsProvider');
}
return context;
}

View File

@@ -23,4 +23,5 @@ export * from './SetConvoContext';
export * from './SearchContext';
export * from './BadgeRowContext';
export * from './SidePanelContext';
export * from './ArtifactsContext';
export { default as BadgeRowProvider } from './BadgeRowContext';

View File

@@ -1,5 +1,5 @@
import debounce from 'lodash/debounce';
import React, { memo, useEffect, useMemo, useCallback } from 'react';
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import {
useSandpack,
SandpackCodeEditor,
@@ -10,8 +10,8 @@ import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { ArtifactFiles, Artifact } from '~/common';
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
import { useEditorContext, useArtifactsContext } from '~/Providers';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
import { useEditorContext } from '~/Providers';
const createDebouncedMutation = (
callback: (params: {
@@ -29,18 +29,21 @@ const CodeEditor = ({
editorRef,
}: {
fileKey: string;
readOnly: boolean;
readOnly?: boolean;
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) => {
const { sandpack } = useSandpack();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating, setCurrentCode } = useEditorContext();
const editArtifact = useEditArtifact({
onMutate: () => {
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
@@ -71,8 +74,14 @@ const CodeEditor = ({
}
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
const isNotOriginal =
currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim();
const isNotRepeated =
currentUpdate == null
? true
: currentCode != null && currentCode.trim() !== currentUpdate.trim();
if (currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim()) {
if (artifact.content && isNotOriginal && isNotRepeated) {
setCurrentCode(currentCode);
debouncedMutation({
index: artifact.index,
@@ -92,8 +101,9 @@ const CodeEditor = ({
artifact.messageId,
readOnly,
isMutating,
sandpack.files,
currentUpdate,
setIsMutating,
sandpack.files,
setCurrentCode,
debouncedMutation,
]);
@@ -102,33 +112,32 @@ const CodeEditor = ({
<SandpackCodeEditor
ref={editorRef}
showTabs={false}
readOnly={readOnly}
showRunButton={false}
showLineNumbers={true}
showInlineErrors={true}
readOnly={readOnly === true}
className="hljs language-javascript bg-black"
/>
);
};
export const ArtifactCodeEditor = memo(function ({
export const ArtifactCodeEditor = function ({
files,
fileKey,
template,
artifact,
editorRef,
sharedProps,
isSubmitting,
}: {
fileKey: string;
artifact: Artifact;
files: ArtifactFiles;
isSubmitting: boolean;
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) {
const { data: config } = useGetStartupConfig();
const { isSubmitting } = useArtifactsContext();
const options: typeof sharedOptions = useMemo(() => {
if (!config) {
return sharedOptions;
@@ -138,6 +147,10 @@ export const ArtifactCodeEditor = memo(function ({
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
};
}, [config, template]);
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
useEffect(() => {
setReadOnly(isSubmitting ?? false);
}, [isSubmitting]);
if (Object.keys(files).length === 0) {
return null;
@@ -154,12 +167,7 @@ export const ArtifactCodeEditor = memo(function ({
{...sharedProps}
template={template}
>
<CodeEditor
editorRef={editorRef}
fileKey={fileKey}
readOnly={isSubmitting}
artifact={artifact}
/>
<CodeEditor fileKey={fileKey} artifact={artifact} editorRef={editorRef} readOnly={readOnly} />
</StyledProvider>
);
});
};

View File

@@ -2,12 +2,12 @@ import { useRef, useEffect } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import type { Artifact } from '~/common';
import { useEditorContext, useArtifactsContext } from '~/Providers';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useGetStartupConfig } from '~/data-provider';
import { ArtifactPreview } from './ArtifactPreview';
import { useEditorContext } from '~/Providers';
import { cn } from '~/utils';
export default function ArtifactTabs({
@@ -15,14 +15,13 @@ export default function ArtifactTabs({
isMermaid,
editorRef,
previewRef,
isSubmitting,
}: {
artifact: Artifact;
isMermaid: boolean;
isSubmitting: boolean;
editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
}) {
const { isSubmitting } = useArtifactsContext();
const { currentCode, setCurrentCode } = useEditorContext();
const { data: startupConfig } = useGetStartupConfig();
const lastIdRef = useRef<string | null>(null);
@@ -52,7 +51,6 @@ export default function ArtifactTabs({
artifact={artifact}
editorRef={editorRef}
sharedProps={sharedProps}
isSubmitting={isSubmitting}
/>
</Tabs.Content>
<Tabs.Content

View File

@@ -29,7 +29,6 @@ export default function Artifacts() {
isMermaid,
setActiveTab,
currentIndex,
isSubmitting,
cycleArtifact,
currentArtifact,
orderedArtifactIds,
@@ -116,7 +115,6 @@ export default function Artifacts() {
<ArtifactTabs
isMermaid={isMermaid}
artifact={currentArtifact}
isSubmitting={isSubmitting}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
/>

View File

@@ -1,6 +1,7 @@
import { useOutletContext, useSearchParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { OpenIDIcon } from '@librechat/client';
import { ErrorTypes } from 'librechat-data-provider';
import { OpenIDIcon, useToastContext } from '@librechat/client';
import { useOutletContext, useSearchParams } from 'react-router-dom';
import type { TLoginLayoutContext } from '~/common';
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
import SocialButton from '~/components/Auth/SocialButton';
@@ -11,6 +12,7 @@ import LoginForm from './LoginForm';
function Login() {
const localize = useLocalize();
const { showToast } = useToastContext();
const { error, setError, login } = useAuthContext();
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
@@ -21,6 +23,19 @@ function Login() {
// Persist the disable flag locally so that once detected, auto-redirect stays disabled.
const [isAutoRedirectDisabled, setIsAutoRedirectDisabled] = useState(disableAutoRedirect);
useEffect(() => {
const oauthError = searchParams?.get('error');
if (oauthError && oauthError === ErrorTypes.AUTH_FAILED) {
showToast({
message: localize('com_auth_error_oauth_failed'),
status: 'error',
});
const newParams = new URLSearchParams(searchParams);
newParams.delete('error');
setSearchParams(newParams, { replace: true });
}
}, [searchParams, setSearchParams, showToast, localize]);
// Once the disable flag is detected, update local state and remove the parameter from the URL.
useEffect(() => {
if (disableAutoRedirect) {

View File

@@ -4,7 +4,7 @@ import { FileSources, LocalStorageKeys } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import { useDeleteFilesMutation } from '~/data-provider';
import DragDropWrapper from '~/components/Chat/Input/Files/DragDropWrapper';
import { EditorProvider, SidePanelProvider } from '~/Providers';
import { EditorProvider, SidePanelProvider, ArtifactsProvider } from '~/Providers';
import Artifacts from '~/components/Artifacts/Artifacts';
import { SidePanelGroup } from '~/components/SidePanel';
import { useSetFilesToDelete } from '~/hooks';
@@ -66,9 +66,11 @@ export default function Presentation({ children }: { children: React.ReactNode }
defaultCollapsed={defaultCollapsed}
artifacts={
artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0 ? (
<EditorProvider>
<Artifacts />
</EditorProvider>
<ArtifactsProvider>
<EditorProvider>
<Artifacts />
</EditorProvider>
</ArtifactsProvider>
) : null
}
>

View File

@@ -25,7 +25,7 @@ type EndpointIcon = {
function getOpenAIColor(_model: string | null | undefined) {
const model = _model?.toLowerCase() ?? '';
if (model && /\b(o\d)\b/i.test(model)) {
if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9]\b/i.test(model))) {
return '#000000';
}
return model.includes('gpt-4') ? '#AB68FF' : '#19C37D';

View File

@@ -32,13 +32,14 @@ function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps)
<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-5 w-5 text-text-tertiary" />
<CircleHelpIcon className="h-6 w-6 cursor-help text-text-secondary transition-colors hover:text-text-primary" />
</div>
}
/>

View File

@@ -1,6 +1,5 @@
// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets
import { ViolationTypes, ErrorTypes, alternateName } from 'librechat-data-provider';
import type { TOpenAIMessage } from 'librechat-data-provider';
import type { LocalizeFunction } from '~/common';
import { formatJSON, extractJson, isJson } from '~/utils/json';
import { useLocalize } from '~/hooks';
@@ -25,7 +24,7 @@ type TTokenBalance = {
prev_count: number;
violation_count: number;
date: Date;
generations?: TOpenAIMessage[];
generations?: unknown[];
};
type TExpiredKey = {
@@ -44,6 +43,17 @@ const errorMessages = {
[ErrorTypes.NO_BASE_URL]: 'com_error_no_base_url',
[ErrorTypes.INVALID_ACTION]: `com_error_${ErrorTypes.INVALID_ACTION}`,
[ErrorTypes.INVALID_REQUEST]: `com_error_${ErrorTypes.INVALID_REQUEST}`,
[ErrorTypes.MISSING_MODEL]: (json: TGenericError, localize: LocalizeFunction) => {
const { info: endpoint } = json;
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
return localize('com_error_missing_model', { 0: provider });
},
[ErrorTypes.MODELS_NOT_LOADED]: 'com_error_models_not_loaded',
[ErrorTypes.ENDPOINT_MODELS_NOT_LOADED]: (json: TGenericError, localize: LocalizeFunction) => {
const { info: endpoint } = json;
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
return localize('com_error_endpoint_models_not_loaded', { 0: provider });
},
[ErrorTypes.NO_SYSTEM_MESSAGES]: `com_error_${ErrorTypes.NO_SYSTEM_MESSAGES}`,
[ErrorTypes.EXPIRED_USER_KEY]: (json: TExpiredKey, localize: LocalizeFunction) => {
const { expiredAt, endpoint } = json;
@@ -65,6 +75,12 @@ const errorMessages = {
[ErrorTypes.GOOGLE_TOOL_CONFLICT]: 'com_error_google_tool_conflict',
[ViolationTypes.BAN]:
'Your account has been temporarily banned due to violations of our service.',
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: (json: TGenericError, localize: LocalizeFunction) => {
const { info } = json;
const [endpoint, model = 'unknown'] = info?.split('|') ?? [];
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
return localize('com_error_illegal_model_request', { 0: model, 1: provider });
},
invalid_api_key:
'Invalid API key. Please check your API key and try again. You can do this by clicking on the model logo in the left corner of the textbox and selecting "Set Token" for the current selected endpoint. Thank you for your understanding.',
insufficient_quota:

View File

@@ -7,7 +7,7 @@ import BackupCodesItem from './BackupCodesItem';
import { useAuthContext } from '~/hooks';
function Account() {
const user = useAuthContext();
const { user } = useAuthContext();
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
@@ -17,12 +17,12 @@ function Account() {
<div className="pb-3">
<Avatar />
</div>
{user?.user?.provider === 'local' && (
{user?.provider === 'local' && (
<>
<div className="pb-3">
<EnableTwoFactorItem />
</div>
{user?.user?.twoFactorEnabled && (
{user?.twoFactorEnabled && (
<div className="pb-3">
<BackupCodesItem />
</div>

View File

@@ -39,8 +39,8 @@ const TwoFactorAuthentication: React.FC = () => {
const [secret, setSecret] = useState<string>('');
const [otpauthUrl, setOtpauthUrl] = useState<string>('');
const [downloaded, setDownloaded] = useState<boolean>(false);
const [disableToken, setDisableToken] = useState<string>('');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [_disableToken, setDisableToken] = useState<string>('');
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const [verificationToken, setVerificationToken] = useState<string>('');
const [phase, setPhase] = useState<Phase>(user?.twoFactorEnabled ? 'disable' : 'setup');
@@ -166,32 +166,26 @@ const TwoFactorAuthentication: React.FC = () => {
payload.token = token.trim();
}
verify2FAMutate(payload, {
disable2FAMutate(payload, {
onSuccess: () => {
disable2FAMutate(undefined, {
onSuccess: () => {
showToast({ message: localize('com_ui_2fa_disabled') });
setDialogOpen(false);
setUser(
(prev) =>
({
...prev,
totpSecret: '',
backupCodes: [],
twoFactorEnabled: false,
}) as TUser,
);
setPhase('setup');
setOtpauthUrl('');
},
onError: () =>
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
});
showToast({ message: localize('com_ui_2fa_disabled') });
setDialogOpen(false);
setUser(
(prev) =>
({
...prev,
totpSecret: '',
backupCodes: [],
twoFactorEnabled: false,
}) as TUser,
);
setPhase('setup');
setOtpauthUrl('');
},
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
});
},
[verify2FAMutate, disable2FAMutate, showToast, localize, setUser],
[disable2FAMutate, showToast, localize, setUser],
);
return (

View File

@@ -0,0 +1,380 @@
/**
* @jest-environment jsdom
*/
import * as React from 'react';
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, waitFor, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Agent } from 'librechat-data-provider';
// Mock toast context - define this after all mocks
let mockShowToast: jest.Mock;
// Mock notification severity enum before other imports
jest.mock('~/common/types', () => ({
NotificationSeverity: {
SUCCESS: 'success',
ERROR: 'error',
INFO: 'info',
WARNING: 'warning',
},
}));
// Mock store to prevent import errors
jest.mock('~/store/toast', () => ({
default: () => ({
showToast: jest.fn(),
}),
}));
jest.mock('~/store', () => {});
// Mock the data service to control network responses
jest.mock('librechat-data-provider', () => {
const actualModule = jest.requireActual('librechat-data-provider') as any;
return {
...actualModule,
dataService: {
updateAgent: jest.fn(),
},
Tools: {
execute_code: 'execute_code',
file_search: 'file_search',
web_search: 'web_search',
},
Constants: {
EPHEMERAL_AGENT_ID: 'ephemeral',
},
SystemRoles: {
ADMIN: 'ADMIN',
},
EModelEndpoint: {
agents: 'agents',
chatGPTBrowser: 'chatGPTBrowser',
gptPlugins: 'gptPlugins',
},
isAssistantsEndpoint: jest.fn(() => false),
};
});
jest.mock('@librechat/client', () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
useToastContext: () => ({
get showToast() {
return mockShowToast || jest.fn();
},
}),
}));
// Mock other dependencies
jest.mock('librechat-data-provider/react-query', () => ({
useGetModelsQuery: () => ({ data: {} }),
}));
jest.mock('~/utils', () => ({
createProviderOption: jest.fn((provider: string) => ({ value: provider, label: provider })),
getDefaultAgentFormValues: jest.fn(() => ({
id: '',
name: '',
description: '',
model: '',
provider: '',
})),
}));
jest.mock('~/hooks', () => ({
useSelectAgent: () => ({ onSelect: jest.fn() }),
useLocalize: () => (key: string) => key,
useAuthContext: () => ({ user: { id: 'user-123', role: 'USER' } }),
}));
jest.mock('~/Providers/AgentPanelContext', () => ({
useAgentPanelContext: () => ({
activePanel: 'builder',
agentsConfig: { allowedProviders: [] },
setActivePanel: jest.fn(),
endpointsConfig: {},
setCurrentAgentId: jest.fn(),
agent_id: 'agent-123',
}),
}));
jest.mock('~/common', () => ({
Panel: {
model: 'model',
builder: 'builder',
advanced: 'advanced',
},
}));
// Mock child components to simplify testing
jest.mock('./AgentPanelSkeleton', () => ({
__esModule: true,
default: () => <div>{`Loading...`}</div>,
}));
jest.mock('./Advanced/AdvancedPanel', () => ({
__esModule: true,
default: () => <div>{`Advanced Panel`}</div>,
}));
jest.mock('./AgentConfig', () => ({
__esModule: true,
default: () => <div>{`Agent Config`}</div>,
}));
jest.mock('./AgentSelect', () => ({
__esModule: true,
default: () => <div>{`Agent Select`}</div>,
}));
jest.mock('./ModelPanel', () => ({
__esModule: true,
default: () => <div>{`Model Panel`}</div>,
}));
// Mock AgentFooter to provide a save button
jest.mock('./AgentFooter', () => ({
__esModule: true,
default: () => (
<button type="submit" data-testid="save-agent-button">
{`Save Agent`}
</button>
),
}));
// Mock react-hook-form to capture form submission
let mockFormSubmitHandler: (() => void) | null = null;
jest.mock('react-hook-form', () => {
const actual = jest.requireActual('react-hook-form') as any;
return {
...actual,
useForm: () => {
const methods = actual.useForm({
defaultValues: {
id: 'agent-123',
name: 'Test Agent',
description: 'Test description',
model: 'gpt-4',
provider: 'openai',
tools: [],
execute_code: false,
file_search: false,
web_search: false,
},
});
return {
...methods,
handleSubmit: (onSubmit: any) => (e?: any) => {
e?.preventDefault?.();
mockFormSubmitHandler = () => onSubmit(methods.getValues());
return mockFormSubmitHandler;
},
};
},
FormProvider: ({ children }: any) => children,
useWatch: () => 'agent-123',
};
});
// Import after mocks
import { dataService } from 'librechat-data-provider';
import { useGetAgentByIdQuery } from '~/data-provider';
import AgentPanel from './AgentPanel';
// Mock useGetAgentByIdQuery
jest.mock('~/data-provider', () => {
const actual = jest.requireActual('~/data-provider') as any;
return {
...actual,
useGetAgentByIdQuery: jest.fn(),
useUpdateAgentMutation: actual.useUpdateAgentMutation,
};
});
// Test wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
// Test helpers
const setupMocks = () => {
const mockUseGetAgentByIdQuery = useGetAgentByIdQuery as jest.MockedFunction<
typeof useGetAgentByIdQuery
>;
const mockUpdateAgent = dataService.updateAgent as jest.MockedFunction<
typeof dataService.updateAgent
>;
return { mockUseGetAgentByIdQuery, mockUpdateAgent };
};
const mockAgentQuery = (
mockUseGetAgentByIdQuery: jest.MockedFunction<typeof useGetAgentByIdQuery>,
agent: Partial<Agent>,
) => {
mockUseGetAgentByIdQuery.mockReturnValue({
data: {
id: 'agent-123',
author: 'user-123',
isCollaborative: false,
...agent,
} as Agent,
isInitialLoading: false,
} as any);
};
const createMockAgent = (overrides: Partial<Agent> = {}): Agent =>
({
id: 'agent-123',
provider: 'openai',
model: 'gpt-4',
...overrides,
}) as Agent;
const renderAndSubmitForm = async () => {
const Wrapper = createWrapper();
const { container, rerender } = render(<AgentPanel />, { wrapper: Wrapper });
const form = container.querySelector('form');
expect(form).toBeTruthy();
fireEvent.submit(form!);
if (mockFormSubmitHandler) {
mockFormSubmitHandler();
}
return { container, rerender, form };
};
describe('AgentPanel - Update Agent Toast Messages', () => {
beforeEach(() => {
jest.clearAllMocks();
mockShowToast = jest.fn();
mockFormSubmitHandler = null;
});
describe('AgentPanel', () => {
it('should show "no changes" toast when version does not change', async () => {
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
// Mock the agent query with version 2
mockAgentQuery(mockUseGetAgentByIdQuery, {
name: 'Test Agent',
version: 2,
});
// Mock network response - same version
mockUpdateAgent.mockResolvedValue(createMockAgent({ name: 'Test Agent', version: 2 }));
await renderAndSubmitForm();
// Wait for the toast to be shown
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_ui_no_changes',
status: 'info',
});
});
});
it('should show "update success" toast when version changes', async () => {
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
// Mock the agent query with version 2
mockAgentQuery(mockUseGetAgentByIdQuery, {
name: 'Test Agent',
version: 2,
});
// Mock network response - different version
mockUpdateAgent.mockResolvedValue(createMockAgent({ name: 'Test Agent', version: 3 }));
await renderAndSubmitForm();
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_assistants_update_success Test Agent',
});
});
});
it('should show "update success" with default name when agent has no name', async () => {
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
// Mock the agent query without name
mockAgentQuery(mockUseGetAgentByIdQuery, {
version: 1,
});
// Mock network response - no name
mockUpdateAgent.mockResolvedValue(createMockAgent({ version: 2 }));
await renderAndSubmitForm();
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_assistants_update_success com_ui_agent',
});
});
});
it('should show "update success" when agent query has no version (undefined)', async () => {
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
// Mock the agent query with no version data
mockAgentQuery(mockUseGetAgentByIdQuery, {
name: 'Test Agent',
// No version property
});
mockUpdateAgent.mockResolvedValue(createMockAgent({ name: 'Test Agent', version: 1 }));
await renderAndSubmitForm();
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_assistants_update_success Test Agent',
});
});
});
it('should show error toast on update failure', async () => {
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
// Mock the agent query
mockAgentQuery(mockUseGetAgentByIdQuery, {
name: 'Test Agent',
version: 1,
});
// Mock network error
mockUpdateAgent.mockRejectedValue(new Error('Update failed'));
await renderAndSubmitForm();
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_agents_update_error com_ui_error: Update failed',
status: 'error',
});
});
});
});
});

View File

@@ -1,5 +1,5 @@
import { Plus } from 'lucide-react';
import React, { useMemo, useCallback } from 'react';
import React, { useMemo, useCallback, useRef } from 'react';
import { Button, useToastContext } from '@librechat/client';
import { useWatch, useForm, FormProvider } from 'react-hook-form';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
@@ -54,6 +54,7 @@ export default function AgentPanel() {
const { control, handleSubmit, reset } = methods;
const agent_id = useWatch({ control, name: 'id' });
const previousVersionRef = useRef<number | undefined>();
const allowedProviders = useMemo(
() => new Set(agentsConfig?.allowedProviders),
@@ -77,50 +78,29 @@ export default function AgentPanel() {
/* Mutations */
const update = useUpdateAgentMutation({
onMutate: () => {
// Store the current version before mutation
previousVersionRef.current = agentQuery.data?.version;
},
onSuccess: (data) => {
showToast({
message: `${localize('com_assistants_update_success')} ${
data.name ?? localize('com_ui_agent')
}`,
});
// Check if agent version is the same (no changes were made)
if (previousVersionRef.current !== undefined && data.version === previousVersionRef.current) {
showToast({
message: localize('com_ui_no_changes'),
status: 'info',
});
} else {
showToast({
message: `${localize('com_assistants_update_success')} ${
data.name ?? localize('com_ui_agent')
}`,
});
}
// Clear the ref after use
previousVersionRef.current = undefined;
},
onError: (err) => {
const error = err as Error & {
statusCode?: number;
details?: { duplicateVersion?: any; versionIndex?: number };
response?: { status?: number; data?: any };
};
const isDuplicateVersionError =
(error.statusCode === 409 && error.details?.duplicateVersion) ||
(error.response?.status === 409 && error.response?.data?.details?.duplicateVersion);
if (isDuplicateVersionError) {
let versionIndex: number | undefined = undefined;
if (error.details?.versionIndex !== undefined) {
versionIndex = error.details.versionIndex;
} else if (error.response?.data?.details?.versionIndex !== undefined) {
versionIndex = error.response.data.details.versionIndex;
}
if (versionIndex === undefined || versionIndex < 0) {
showToast({
message: localize('com_agents_update_error'),
status: 'error',
duration: 5000,
});
} else {
showToast({
message: localize('com_ui_agent_version_duplicate', { versionIndex: versionIndex + 1 }),
status: 'error',
duration: 10000,
});
}
return;
}
const error = err as Error;
showToast({
message: `${localize('com_agents_update_error')}${
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''

View File

@@ -43,11 +43,7 @@ export const useCreateAgentMutation = (
*/
export const useUpdateAgentMutation = (
options?: t.UpdateAgentMutationOptions,
): UseMutationResult<
t.Agent,
t.DuplicateVersionError,
{ agent_id: string; data: t.AgentUpdateParams }
> => {
): UseMutationResult<t.Agent, Error, { agent_id: string; data: t.AgentUpdateParams }> => {
const queryClient = useQueryClient();
return useMutation(
({ agent_id, data }: { agent_id: string; data: t.AgentUpdateParams }) => {
@@ -59,8 +55,7 @@ export const useUpdateAgentMutation = (
{
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => {
const typedError = error as t.DuplicateVersionError;
return options?.onError?.(typedError, variables, context);
return options?.onError?.(error, variables, context);
},
onSuccess: (updatedAgent, variables, context) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([

View File

@@ -134,12 +134,12 @@ export const useConfirmTwoFactorMutation = (): UseMutationResult<
export const useDisableTwoFactorMutation = (): UseMutationResult<
t.TDisable2FAResponse,
unknown,
void,
t.TDisable2FARequest | undefined,
unknown
> => {
const queryClient = useQueryClient();
return useMutation(() => dataService.disableTwoFactor(), {
onSuccess: (data) => {
return useMutation((payload?: t.TDisable2FARequest) => dataService.disableTwoFactor(payload), {
onSuccess: () => {
queryClient.setQueryData([QueryKeys.user, '2fa'], null);
},
});

View File

@@ -1,14 +1,15 @@
import { useMemo, useState, useEffect, useRef } from 'react';
import { Constants } from 'librechat-data-provider';
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil';
import { getLatestText, logger } from '~/utils';
import { useChatContext } from '~/Providers';
import { logger } from '~/utils';
import { useArtifactsContext } from '~/Providers';
import { getKey } from '~/utils/artifacts';
import store from '~/store';
export default function useArtifacts() {
const [activeTab, setActiveTab] = useState('preview');
const { isSubmitting, latestMessage, conversation } = useChatContext();
const { isSubmitting, latestMessageId, latestMessageText, conversationId } =
useArtifactsContext();
const artifacts = useRecoilValue(store.artifactsState);
const resetArtifacts = useResetRecoilState(store.artifactsState);
@@ -31,26 +32,23 @@ export default function useArtifacts() {
const resetState = () => {
resetArtifacts();
resetCurrentArtifactId();
prevConversationIdRef.current = conversation?.conversationId ?? null;
prevConversationIdRef.current = conversationId;
lastRunMessageIdRef.current = null;
lastContentRef.current = null;
hasEnclosedArtifactRef.current = false;
};
if (
conversation?.conversationId !== prevConversationIdRef.current &&
prevConversationIdRef.current != null
) {
if (conversationId !== prevConversationIdRef.current && prevConversationIdRef.current != null) {
resetState();
} else if (conversation?.conversationId === Constants.NEW_CONVO) {
} else if (conversationId === Constants.NEW_CONVO) {
resetState();
}
prevConversationIdRef.current = conversation?.conversationId ?? null;
prevConversationIdRef.current = conversationId;
/** Resets artifacts when unmounting */
return () => {
logger.log('artifacts_visibility', 'Unmounting artifacts');
resetState();
};
}, [conversation?.conversationId, resetArtifacts, resetCurrentArtifactId]);
}, [conversationId, resetArtifacts, resetCurrentArtifactId]);
useEffect(() => {
if (orderedArtifactIds.length > 0) {
@@ -66,7 +64,7 @@ export default function useArtifacts() {
if (orderedArtifactIds.length === 0) {
return;
}
if (latestMessage == null) {
if (latestMessageId == null) {
return;
}
const latestArtifactId = orderedArtifactIds[orderedArtifactIds.length - 1];
@@ -78,7 +76,6 @@ export default function useArtifacts() {
setCurrentArtifactId(latestArtifactId);
lastContentRef.current = latestArtifact?.content ?? null;
const latestMessageText = getLatestText(latestMessage);
const hasEnclosedArtifact =
/:::artifact(?:\{[^}]*\})?(?:\s|\n)*(?:```[\s\S]*?```(?:\s|\n)*)?:::/m.test(
latestMessageText.trim(),
@@ -95,15 +92,22 @@ export default function useArtifacts() {
hasAutoSwitchedToCodeRef.current = true;
}
}
}, [setCurrentArtifactId, isSubmitting, orderedArtifactIds, artifacts, latestMessage]);
}, [
artifacts,
isSubmitting,
latestMessageId,
latestMessageText,
orderedArtifactIds,
setCurrentArtifactId,
]);
useEffect(() => {
if (latestMessage?.messageId !== lastRunMessageIdRef.current) {
lastRunMessageIdRef.current = latestMessage?.messageId ?? null;
if (latestMessageId !== lastRunMessageIdRef.current) {
lastRunMessageIdRef.current = latestMessageId;
hasEnclosedArtifactRef.current = false;
hasAutoSwitchedToCodeRef.current = false;
}
}, [latestMessage]);
}, [latestMessageId]);
const currentArtifact = currentArtifactId != null ? artifacts?.[currentArtifactId] : null;
@@ -131,7 +135,6 @@ export default function useArtifacts() {
isMermaid,
setActiveTab,
currentIndex,
isSubmitting,
cycleArtifact,
currentArtifact,
orderedArtifactIds,

View File

@@ -81,7 +81,9 @@ export function useMCPServerManager() {
return initialStates;
});
const { data: connectionStatusData } = useMCPConnectionStatusQuery();
const { data: connectionStatusData } = useMCPConnectionStatusQuery({
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
});
const connectionStatus = useMemo(
() => connectionStatusData?.connectionStatus || {},
[connectionStatusData?.connectionStatus],
@@ -158,6 +160,8 @@ export function useMCPServerManager() {
setMCPValues([...currentValues, serverName]);
}
await queryClient.invalidateQueries([QueryKeys.tools]);
// This delay is to ensure UI has updated with new connection status before cleanup
// Otherwise servers will show as disconnected for a second after OAuth flow completes
setTimeout(() => {

View File

@@ -589,6 +589,7 @@
"com_ui_copy_to_clipboard": "Copia al porta-retalls",
"com_ui_create": "Crea",
"com_ui_create_link": "Crea enllaç",
"com_ui_create_memory": "Crear memòria",
"com_ui_create_prompt": "Crea prompt",
"com_ui_currently_production": "Actualment en producció",
"com_ui_custom": "Personalitzat",
@@ -622,6 +623,7 @@
"com_ui_delete_confirm": "Això eliminarà",
"com_ui_delete_confirm_prompt_version_var": "Això eliminarà la versió seleccionada per a \"{{0}}.\" Si no hi ha altres versions, s'eliminarà el prompt.",
"com_ui_delete_conversation": "Vols eliminar el xat?",
"com_ui_delete_memory": "Esborrar memòria",
"com_ui_delete_prompt": "Vols eliminar el prompt?",
"com_ui_delete_shared_link": "Vols eliminar l'enllaç compartit?",
"com_ui_delete_tool": "Elimina eina",

View File

@@ -516,7 +516,6 @@
"com_ui_agent_var": "{{0}} agent",
"com_ui_agent_version": "Version",
"com_ui_agent_version_active": "Aktiv version",
"com_ui_agent_version_duplicate": "Duplikatversion fundet. Dette vil skabe en version, der er identisk med Version {{versionIndex}}.",
"com_ui_agent_version_empty": "Ingen tilgængelige versioner",
"com_ui_agent_version_error": "Fejl ved hentning af versioner",
"com_ui_agent_version_history": "Versionshistorik",

View File

@@ -548,7 +548,6 @@
"com_ui_agent_var": "{{0}} Agent",
"com_ui_agent_version": "Version",
"com_ui_agent_version_active": "Aktive Version\n",
"com_ui_agent_version_duplicate": "Doppelte Version entdeckt. Dies würde eine Version erzeugen, die identisch mit der Version {{versionIndex}} ist.",
"com_ui_agent_version_empty": "Keine Versionen verfügbar\n",
"com_ui_agent_version_error": "Fehler beim Abrufen der Versionen",
"com_ui_agent_version_history": "Versionsgeschichte\n",

View File

@@ -107,6 +107,7 @@
"com_auth_error_login_rl": "Too many login attempts in a short amount of time. Please try again later.",
"com_auth_error_login_server": "There was an internal server error. Please wait a few moments and try again.",
"com_auth_error_login_unverified": "Your account has not been verified. Please check your email for a verification link.",
"com_auth_error_oauth_failed": "Authentication failed. Please check your login method and try again.",
"com_auth_facebook_login": "Continue with Facebook",
"com_auth_full_name": "Full name",
"com_auth_github_login": "Continue with Github",
@@ -229,7 +230,7 @@
"com_endpoint_openai_max_tokens": "Optional 'max_tokens' field, representing the maximum number of tokens that can be generated in the chat completion. The total length of input tokens and generated tokens is limited by the models context length. You may experience errors if this number exceeds the max context tokens.",
"com_endpoint_openai_pres": "Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.",
"com_endpoint_openai_prompt_prefix_placeholder": "Set custom instructions to include in System Message. Default: none",
"com_endpoint_openai_reasoning_effort": "o1 and o3 models only: constrains effort on reasoning for reasoning models. Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response.",
"com_endpoint_openai_reasoning_effort": "Reasoning models only: constrains effort on reasoning. Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response. 'Minimal' produces very few reasoning tokens for fastest time-to-first-token, especially well-suited for coding and instruction following.",
"com_endpoint_openai_reasoning_summary": "Responses API only: A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. Set to none,auto, concise, or detailed.",
"com_endpoint_openai_resend": "Resend all previously attached images. Note: this can significantly increase token cost and you may experience errors with many image attachments.",
"com_endpoint_openai_resend_files": "Resend all previously attached files. Note: this will increase token cost and you may experience errors with many attachments.",
@@ -238,6 +239,7 @@
"com_endpoint_openai_topp": "An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We recommend altering this or temperature but not both.",
"com_endpoint_openai_use_responses_api": "Use the Responses API instead of Chat Completions, which includes extended features from OpenAI. Required for o1-pro, o3-pro, and to enable reasoning summaries.",
"com_endpoint_openai_use_web_search": "Enable web search functionality using OpenAI's built-in search capabilities. This allows the model to search the web for up-to-date information and provide more accurate, current responses.",
"com_endpoint_openai_verbosity": "Constrains the verbosity of the model's response. Lower values will result in more concise responses, while higher values will result in more verbose responses. Currently supported values are low, medium, and high.",
"com_endpoint_output": "Output",
"com_endpoint_plug_image_detail": "Image Detail",
"com_endpoint_plug_resend_files": "Resend Files",
@@ -286,6 +288,8 @@
"com_endpoint_use_active_assistant": "Use Active Assistant",
"com_endpoint_use_responses_api": "Use Responses API",
"com_endpoint_use_search_grounding": "Grounding with Google Search",
"com_endpoint_verbosity": "Verbosity",
"com_error_endpoint_models_not_loaded": "Models for {{0}} could not be loaded. Please refresh the page and try again.",
"com_error_expired_user_key": "Provided key for {{0}} expired at {{1}}. Please provide a new key and try again.",
"com_error_files_dupe": "Duplicate file detected.",
"com_error_files_empty": "Empty files are not allowed.",
@@ -296,9 +300,12 @@
"com_error_files_validation": "An error occurred while validating the file.",
"com_error_google_tool_conflict": "Usage of built-in Google tools are not supported with external tools. Please disable either the built-in tools or the external tools.",
"com_error_heic_conversion": "Failed to convert HEIC image to JPEG. Please try converting the image manually or use a different format.",
"com_error_illegal_model_request": "The model \"{{0}}\" is not available for {{1}}. Please select a different model.",
"com_error_input_length": "The latest message token count is too long, exceeding the token limit, or your token limit parameters are misconfigured, adversely affecting the context window. More info: {{0}}. Please shorten your message, adjust the max context size from the conversation parameters, or fork the conversation to continue.",
"com_error_invalid_agent_provider": "The \"{{0}}\" provider is not available for use with Agents. Please go to your agent's settings and select a currently available provider.",
"com_error_invalid_user_key": "Invalid key provided. Please provide a valid key and try again.",
"com_error_missing_model": "No model selected for {{0}}. Please select a model and try again.",
"com_error_models_not_loaded": "Models configuration could not be loaded. Please refresh the page and try again.",
"com_error_moderation": "It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We're unable to proceed with this specific topic. If you have any other questions or topics you'd like to explore, please edit your message, or create a new conversation.",
"com_error_no_base_url": "No base URL found. Please provide one and try again.",
"com_error_no_user_key": "No key found. Please provide a key and try again.",
@@ -448,7 +455,7 @@
"com_nav_maximize_chat_space": "Maximize chat space",
"com_nav_mcp_configure_server": "Configure {{0}}",
"com_nav_mcp_status_connecting": "{{0}} - Connecting",
"com_nav_mcp_vars_update_error": "Error updating MCP custom user variables: {{0}}",
"com_nav_mcp_vars_update_error": "Error updating MCP custom user variables",
"com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.",
"com_nav_modular_chat": "Enable switching Endpoints mid-conversation",
"com_nav_my_files": "My Files",
@@ -554,7 +561,6 @@
"com_ui_agent_var": "{{0}} agent",
"com_ui_agent_version": "Version",
"com_ui_agent_version_active": "Active Version",
"com_ui_agent_version_duplicate": "Duplicate version detected. This would create a version identical to Version {{versionIndex}}.",
"com_ui_agent_version_empty": "No versions available",
"com_ui_agent_version_error": "Error fetching versions",
"com_ui_agent_version_history": "Version History",
@@ -882,6 +888,7 @@
"com_ui_memory_would_exceed": "Cannot save - would exceed limit by {{tokens}} tokens. Delete existing memories to make space.",
"com_ui_mention": "Mention an endpoint, assistant, or preset to quickly switch to it",
"com_ui_min_tags": "Cannot remove more values, a minimum of {{0}} are required.",
"com_ui_minimal": "Minimal",
"com_ui_misc": "Misc.",
"com_ui_model": "Model",
"com_ui_model_parameters": "Model Parameters",

View File

@@ -517,7 +517,6 @@
"com_ui_agent_var": "{{0}} agent",
"com_ui_agent_version": "Versioon",
"com_ui_agent_version_active": "Aktiivne versioon",
"com_ui_agent_version_duplicate": "Tuvastati duplikaatversioon. See looks versiooni, mis on identne versiooniga {{versionIndex}}.",
"com_ui_agent_version_empty": "Versioone pole saadaval",
"com_ui_agent_version_error": "Viga versioonide laadimisel",
"com_ui_agent_version_history": "Versioonide ajalugu",

View File

@@ -131,6 +131,7 @@
"com_auth_submit_registration": "Lähetä rekisteröityminen",
"com_auth_to_reset_your_password": "asettaaksesi uuden salasanan.",
"com_auth_to_try_again": "kokeillaksesi uudestaan.",
"com_auth_two_factor": "Katso kertakäyttöinen koodi tunnistautumissovelluksestasi",
"com_auth_username": "Käyttäjänimi (valinnainen)",
"com_auth_username_max_length": "Käyttäjänimi voi olla enintään 20 merkkiä pitkä",
"com_auth_username_min_length": "Käyttäjänimessä on oltava vähintään 2 merkkiä",

View File

@@ -547,7 +547,6 @@
"com_ui_agent_var": "agent {{0}}",
"com_ui_agent_version": "Version",
"com_ui_agent_version_active": "Version active",
"com_ui_agent_version_duplicate": "Duplicata de version détecté. Cela créerait une version identique à la version {{versionIndex}}",
"com_ui_agent_version_empty": "Aucune version disponible",
"com_ui_agent_version_error": "Erreur lors de la collecte des versions",
"com_ui_agent_version_history": "Historique des versions",
@@ -573,6 +572,7 @@
"com_ui_archive_error": "échec de l'archivage de la conversation",
"com_ui_artifact_click": "Cliquer pour ouvrir",
"com_ui_artifacts": "Artefacts",
"com_ui_artifacts_options": "Options des Artefacts",
"com_ui_artifacts_toggle": "Afficher/Masquer l'interface des artefacts",
"com_ui_artifacts_toggle_agent": "Activer Artifacts",
"com_ui_ascending": "Croissant",
@@ -772,6 +772,7 @@
"com_ui_fork_change_default": "Option de fourche par défaut",
"com_ui_fork_default": "Utiliser l'option de fourche par défaut",
"com_ui_fork_error": "Une erreur s'est produite lors du dédoublement de la conversation",
"com_ui_fork_error_rate_limit": "Trop de demandes de duplications. Merci de réessayer plus tard.",
"com_ui_fork_from_message": "Sélectionner une option de bifurcation",
"com_ui_fork_info_1": "Utilisez ce paramètre pour créer une bifurcation des messages avec le comportement souhaité.",
"com_ui_fork_info_2": "\"Forker\" fait référence à la création d'une nouvelle conversation qui commence/se termine à partir de messages spécifiques dans la conversation actuelle, en créant une copie selon les options sélectionnées.",
@@ -1065,6 +1066,7 @@
"com_ui_web_search_scraper": "Extracteur (scraper)",
"com_ui_web_search_scraper_firecrawl": "API de Firecrawl",
"com_ui_web_search_scraper_firecrawl_key": "Obtenez votre clé API pour Firecrawl",
"com_ui_web_search_searxng_api_key": "Entrez la clé d'API de SearXNG (facultatif)",
"com_ui_web_search_searxng_instance_url": "Adresse URL de l'instance SearXNG",
"com_ui_web_searching": "Rechercher sur le web",
"com_ui_web_searching_again": "Rechercher à nouveau sur le web",

View File

@@ -282,6 +282,7 @@
"com_endpoint_top_k": "Top K",
"com_endpoint_top_p": "Top P",
"com_endpoint_use_active_assistant": "השתמש ב-סייען פעיל",
"com_endpoint_use_responses_api": "השתמש ב-API של תגובות",
"com_endpoint_use_search_grounding": "התבססות על חיפוש גוגל",
"com_error_expired_user_key": "המפתח שסופק עבור {{0}} פג ב-{{1}}. אנא ספק מפתח חדש ונסה שוב.",
"com_error_files_dupe": "זוהה קובץ כפול",
@@ -540,7 +541,6 @@
"com_ui_agent_var": "{{0}} סוכנים",
"com_ui_agent_version": "גרסה",
"com_ui_agent_version_active": "גרסת הפעלה",
"com_ui_agent_version_duplicate": "זוהתה גרסה כפולה, פעולה זו תיצור גרסה זהה לגרסה {{versionIndex}}.",
"com_ui_agent_version_empty": "אין גרסאות זמינות",
"com_ui_agent_version_error": "שגיאה באחזור גרסאות",
"com_ui_agent_version_history": "היסטוריית גרסאות",

View File

@@ -198,6 +198,8 @@
"com_endpoint_deprecated": "非推奨",
"com_endpoint_deprecated_info": "このエンドポイントは非推奨であり、将来のバージョンで削除される可能性があります。",
"com_endpoint_deprecated_info_a11y": "プラグインエンドポイントは非推奨であり、将来のバージョンで削除される可能性があります。",
"com_endpoint_disable_streaming": "ストリーミング応答を無効にし、完全な応答を一度に受信する。o3 のように、ストリーミングのための組織検証を必要とするモデルに便利です。",
"com_endpoint_disable_streaming_label": "ストリーミングを無効にする",
"com_endpoint_examples": " プリセット名",
"com_endpoint_export": "エクスポート",
"com_endpoint_export_share": "エクスポート/共有",
@@ -227,7 +229,7 @@
"com_endpoint_openai_max_tokens": "オプションの 'max_tokens' フィールドで、チャット補完時に生成可能な最大トークン数を設定します。入力トークンと生成されたトークンの合計長さは、モデルのコンテキスト長によって制限されています。この数値がコンテキストの最大トークン数を超えると、エラーが発生する可能性があります。",
"com_endpoint_openai_pres": "-2.0から2.0の値。正の値は入力すると、新規トークンの出現に基づいたペナルティを課し、新しいトピックについて話す可能性を高める。",
"com_endpoint_openai_prompt_prefix_placeholder": "システムメッセージに含める Custom Instructions。デフォルト: none",
"com_endpoint_openai_reasoning_effort": "o1 モデルのみ: 推論モデルの推論の努力を制限します。推論の努力を減らす、応答が速くなり、応答推論に使用されるトークンが少なくなります。",
"com_endpoint_openai_reasoning_effort": "推論モデルのみ:推論の努力を制限します。推論の努力を減らすことで、応答が速くなり、応答における推論に使用されるトークンが少なくなります。「最小限」は、特にコーディングや指示のフォローに適しており、最初のトークンまでの時間を最速にするためにごくわずかな推論トークンを生成します。",
"com_endpoint_openai_reasoning_summary": "Responses APIのみモデルが実行した推論の概要。これは、モデルの推論プロセスのデバッグや理解に役立ちます。none、auto、concise、detailedのいずれかに設定してください。",
"com_endpoint_openai_resend": "これまでに添付した画像を全て再送信します。注意:トークン数が大幅に増加したり、多くの画像を添付するとエラーが発生する可能性があります。",
"com_endpoint_openai_resend_files": "以前に添付されたすべてのファイルを再送信します。注意:これにより、トークンのコストが増加し、多くの添付ファイルでエラーが発生する可能性があります。",
@@ -236,6 +238,7 @@
"com_endpoint_openai_topp": "nucleus sampling と呼ばれるtemperatureを使用したサンプリングの代わりに、top_p確率質量のトークンの結果を考慮します。つまり、0.1とすると確率質量の上位10%を構成するトークンのみが考慮されます。この値かtemperatureの変更をおすすめしますが、両方を変更はおすすめしません。",
"com_endpoint_openai_use_responses_api": "Chat Completions の代わりに、OpenAI の拡張機能を含む Responses API を使用してください。o1-pro、o3-pro、および推論要約を有効にするために必要です。",
"com_endpoint_openai_use_web_search": "OpenAIの組み込み検索機能を使用して、ウェブ検索機能を有効にします。これにより、モデルは最新の情報をウェブで検索し、より正確で最新の回答を提供できるようになります。",
"com_endpoint_openai_verbosity": "モデルの応答の冗長性を制限します。値が低いほど簡潔な応答となり、値が高いほど冗長な応答となります。現在サポートされている値はlow、medium、highです。",
"com_endpoint_output": "出力",
"com_endpoint_plug_image_detail": "画像の詳細",
"com_endpoint_plug_resend_files": "ファイルを再送",
@@ -284,6 +287,7 @@
"com_endpoint_use_active_assistant": "アクティブなアシスタントを使用",
"com_endpoint_use_responses_api": "レスポンスAPIの使用",
"com_endpoint_use_search_grounding": "Google検索でグラウンディング",
"com_endpoint_verbosity": "冗長性",
"com_error_expired_user_key": "{{0}}の提供されたキーは{{1}}で期限切れです。キーを入力して再試行してください。",
"com_error_files_dupe": "重複したファイルが検出されました。",
"com_error_files_empty": "空のファイルはアップロードできません",
@@ -324,6 +328,7 @@
"com_nav_auto_transcribe_audio": "オーディオを自動で書き起こす",
"com_nav_automatic_playback": "最新メッセージを自動再生",
"com_nav_balance": "バランス",
"com_nav_balance_auto_refill_disabled": "自動補充は無効。",
"com_nav_balance_auto_refill_error": "自動補充設定の読み込み中にエラーが発生しました。",
"com_nav_balance_auto_refill_settings": "自動補充設定",
"com_nav_balance_day": "日",
@@ -432,8 +437,10 @@
"com_nav_lang_spanish": "Español",
"com_nav_lang_swedish": "Svenska",
"com_nav_lang_thai": "ไทย",
"com_nav_lang_tibetan": "བོད་ སྐད་",
"com_nav_lang_traditional_chinese": "繁體中文",
"com_nav_lang_turkish": "Türkçe",
"com_nav_lang_ukrainian": "ウクラエンスカ",
"com_nav_lang_uyghur": "ウラジオストク",
"com_nav_lang_vietnamese": "Tiếng Việt",
"com_nav_language": "言語",
@@ -441,6 +448,8 @@
"com_nav_log_out": "ログアウト",
"com_nav_long_audio_warning": "長いテキストの処理には時間がかかります。",
"com_nav_maximize_chat_space": "チャット画面を最大化",
"com_nav_mcp_configure_server": "{{0}}を設定",
"com_nav_mcp_status_connecting": "{{0}} - 接続中",
"com_nav_mcp_vars_update_error": "MCP カスタムユーザ変数の更新エラー: {{0}}",
"com_nav_mcp_vars_updated": "MCP カスタムユーザー変数が正常に更新されました。",
"com_nav_modular_chat": "会話の途中でのエンドポイント切替を有効化",
@@ -520,6 +529,7 @@
"com_ui_2fa_verified": "2要素認証の認証に成功しました",
"com_ui_accept": "同意します",
"com_ui_action_button": "アクションボタン",
"com_ui_active": "有効化",
"com_ui_add": "追加",
"com_ui_add_mcp": "MCPの追加",
"com_ui_add_mcp_server": "MCPサーバーの追加",
@@ -546,7 +556,6 @@
"com_ui_agent_var": "{{0}}エージェント",
"com_ui_agent_version": "バージョン",
"com_ui_agent_version_active": "アクティブバージョン",
"com_ui_agent_version_duplicate": "重複バージョンが検出されました。これにより、バージョン{{versionIndex}}と同一のバージョンが作成されます。",
"com_ui_agent_version_empty": "利用可能なバージョンはありません",
"com_ui_agent_version_error": "バージョン取得エラー",
"com_ui_agent_version_history": "バージョン履歴",
@@ -590,6 +599,7 @@
"com_ui_attachment": "添付ファイル",
"com_ui_auth_type": "認証タイプ",
"com_ui_auth_url": "認証URL",
"com_ui_authenticate": "認証",
"com_ui_authentication": "認証",
"com_ui_authentication_type": "認証タイプ",
"com_ui_auto": "自動",
@@ -647,8 +657,10 @@
"com_ui_confirm_action": "実行する",
"com_ui_confirm_admin_use_change": "この設定を変更すると、あなた自身を含む管理者のアクセスがブロックされます。本当によろしいですか?",
"com_ui_confirm_change": "変更の確認",
"com_ui_connecting": "接続中",
"com_ui_context": "コンテキスト",
"com_ui_continue": "続ける",
"com_ui_continue_oauth": "OAuthで続行",
"com_ui_controls": "管理",
"com_ui_convo_delete_error": "会話の削除に失敗しました",
"com_ui_copied": "コピーしました!",
@@ -838,9 +850,16 @@
"com_ui_low": "低い",
"com_ui_manage": "管理",
"com_ui_max_tags": "最新の値を使用した場合、許可される最大数は {{0}} です。",
"com_ui_mcp_authenticated_success": "MCPサーバー{{0}}認証成功",
"com_ui_mcp_enter_var": "{{0}}の値を入力する。",
"com_ui_mcp_init_failed": "MCPサーバーの初期化に失敗しました",
"com_ui_mcp_initialize": "初期化",
"com_ui_mcp_initialized_success": "MCPサーバー{{0}}初期化に成功",
"com_ui_mcp_oauth_cancelled": "OAuthログインがキャンセルされた {{0}}",
"com_ui_mcp_oauth_timeout": "OAuthログインがタイムアウトしました。 {{0}}",
"com_ui_mcp_server_not_found": "サーバーが見つかりません。",
"com_ui_mcp_servers": "MCP サーバー",
"com_ui_mcp_update_var": "{{0}}を更新",
"com_ui_mcp_url": "MCPサーバーURL",
"com_ui_medium": "中",
"com_ui_memories": "メモリ",
@@ -864,6 +883,7 @@
"com_ui_memory_would_exceed": "保存できません - 制限を超えています {{tokens}} トークン。既存のメモリを削除してスペースを確保します。",
"com_ui_mention": "エンドポイント、アシスタント、またはプリセットを素早く切り替えるには、それらを言及してください。",
"com_ui_min_tags": "これ以上の値を削除できません。少なくとも {{0}} が必要です。",
"com_ui_minimal": "最小限",
"com_ui_misc": "その他",
"com_ui_model": "モデル",
"com_ui_model_parameters": "モデルパラメータ",
@@ -899,6 +919,7 @@
"com_ui_oauth_success_title": "認証成功",
"com_ui_of": "of",
"com_ui_off": "オフ",
"com_ui_offline": "オフライン",
"com_ui_on": "オン",
"com_ui_openai": "OpenAI",
"com_ui_optional": "(任意)",
@@ -931,6 +952,7 @@
"com_ui_regenerate_backup": "バックアップコードの再生成",
"com_ui_regenerating": "再生成中...",
"com_ui_region": "地域",
"com_ui_reinitialize": "再初期化",
"com_ui_rename": "タイトル変更",
"com_ui_rename_conversation": "会話の名前を変更する",
"com_ui_rename_failed": "会話の名前を変更できませんでした",
@@ -970,6 +992,7 @@
"com_ui_select_search_plugin": "プラグイン名で検索",
"com_ui_select_search_provider": "プロバイダー名で検索",
"com_ui_select_search_region": "地域名で検索",
"com_ui_set": "セット",
"com_ui_share": "共有",
"com_ui_share_create_message": "あなたの名前と共有リンクを作成した後のメッセージは、共有されません。",
"com_ui_share_delete_error": "共有リンクの削除中にエラーが発生しました。",
@@ -1022,6 +1045,7 @@
"com_ui_unarchive": "アーカイブ解除",
"com_ui_unarchive_error": "アーカイブ解除に失敗しました。",
"com_ui_unknown": "不明",
"com_ui_unset": "設定解除",
"com_ui_untitled": "無題",
"com_ui_update": "更新",
"com_ui_update_mcp_error": "MCPの作成または更新にエラーが発生しました。",

View File

@@ -21,6 +21,8 @@
"com_agents_mcp_icon_size": "최소 크기 128 x 128 픽셀",
"com_agents_mcp_info": "에이전트가 작업을 수행하고 외부 서비스와 연동할 수 있도록 MCP 서버를 추가하세요",
"com_agents_mcp_name_placeholder": "커스텀 툴",
"com_agents_mcp_trust_subtext": "사용자 지정 커넥터는 LibreChat에서 확인되지 않습니다.",
"com_agents_mcps_disabled": "MCP를 추가하려면 먼저 에이전트를 생성해야 합니다.",
"com_agents_missing_provider_model": "에이전트를 생성하기 전에 제공업체와 모델을 선택해 주세요",
"com_agents_name_placeholder": "선택 사항: 에이전트의 이름",
"com_agents_no_access": "이 에이전트를 수정할 권한이 없습니다",
@@ -337,6 +339,7 @@
"com_nav_balance_minute": "분",
"com_nav_balance_minutes": "분",
"com_nav_balance_month": "월",
"com_nav_balance_months": "월",
"com_nav_balance_next_refill": "다음 충전:",
"com_nav_balance_next_refill_info": "다음 충전은 두 조건이 모두 충족될 때만 자동으로 발생합니다: 마지막 충전 이후 지정된 시간 간격이 지났고, 프롬프트를 보내면 잔액이 0 아래로 떨어질 때입니다.",
"com_nav_balance_refill_amount": "충전 금액:",
@@ -549,7 +552,6 @@
"com_ui_agent_var": "{{0}} 에이전트",
"com_ui_agent_version": "버전",
"com_ui_agent_version_active": "활성 버전",
"com_ui_agent_version_duplicate": "중복 버전이 감지되었습니다. 이는 버전 {{versionIndex}}와 동일한 버전을 생성합니다.",
"com_ui_agent_version_empty": "사용 가능한 버전이 없습니다",
"com_ui_agent_version_error": "버전 정보 가져오기 오류",
"com_ui_agent_version_history": "버전 기록",
@@ -904,6 +906,7 @@
"com_ui_oauth_connected_to": "연결됨:",
"com_ui_oauth_error_callback_failed": "인증 콜백이 실패했습니다. 다시 시도하세요.",
"com_ui_oauth_error_generic": "인증이 실패했습니다. 다시 시도하세요.",
"com_ui_oauth_error_invalid_state": "잘못된 상태값입니다. 다시 시도하세요.",
"com_ui_oauth_error_missing_code": "인증 코드가 누락되었습니다. 다시 시도하세요.",
"com_ui_oauth_error_missing_state": "상태 파라미터가 누락되었습니다. 다시 시도하세요.",
"com_ui_oauth_error_title": "인증 실패",

View File

@@ -96,7 +96,7 @@
"com_auth_email_verification_failed_token_missing": "Verifikācija neizdevās, trūkst tokena",
"com_auth_email_verification_in_progress": "Jūsu e-pasta verifikācija, lūdzu, uzgaidiet",
"com_auth_email_verification_invalid": "Nederīga e-pasta verifikācija",
"com_auth_email_verification_redirecting": "Pārvirzīšana {{0}} sekundēs...",
"com_auth_email_verification_redirecting": "Pārvirzu {{0}} sekundēs...",
"com_auth_email_verification_resend_prompt": "Nesaņēmāt e-pastu?",
"com_auth_email_verification_success": "E-pasts veiksmīgi pārbaudīts",
"com_auth_email_verifying_ellipsis": "Pārbauda...",
@@ -155,7 +155,7 @@
"com_endpoint_ai": "Mākslīgais intelekts",
"com_endpoint_anthropic_maxoutputtokens": "Maksimālais atbildē ģenerējamo tokenu skaits. Norādiet zemāku vērtību īsākām atbildēm un augstāku vērtību garākām atbildēm. Piezīme: modeļi var apstāties pirms šī maksimālā skaita sasniegšanas.",
"com_endpoint_anthropic_prompt_cache": "Uzvednes kešatmiņa ļauj atkārtoti izmantot lielu kontekstu vai instrukcijas API izsaukumos, samazinot izmaksas un ābildes ātrumu.",
"com_endpoint_anthropic_temp": "Diapazons no 0 līdz 1. Analītiskiem/atbilžu variantiem izmantojiet temp vērtību tuvāk 0, bet radošiem un ģeneratīviem uzdevumiem — tuvāk 1. Iesakām mainīt šo vai Top P, bet ne abus.",
"com_endpoint_anthropic_temp": "Diapazons no 0 līdz 1. Analītiskiem/atbilžu variantiem izmantot temp vērtību tuvāk 0, bet radošiem un ģeneratīviem uzdevumiem — tuvāk 1. Iesakām mainīt šo vai Top P, bet ne abus.",
"com_endpoint_anthropic_thinking": "Iespējo iekšējo domāšanu atbalstītajiem Claude modeļiem (3.7 Sonnet). Piezīme: nepieciešams iestatīt \"Domāšanas budžetu\", kam arī jābūt zemākam par \"Max Output Tokens\".",
"com_endpoint_anthropic_thinking_budget": "Nosaka maksimālo žetonu skaitu, ko Claude drīkst izmantot savā iekšējā domāšanas procesā. Lielāki budžeti var uzlabot atbilžu kvalitāti, nodrošinot rūpīgāku analīzi sarežģītām problēmām, lai gan Claude var neizmantot visu piešķirto budžetu, īpaši diapazonos virs 32 000. Šim iestatījumam jābūt zemākam par \"Maksimālie izvades tokeni\".",
"com_endpoint_anthropic_topk": "Top-k maina to, kā modelis atlasa marķierus izvadei. Ja top-k ir 1, tas nozīmē, ka atlasītais marķieris ir visticamākais starp visiem modeļa vārdu krājumā esošajiem marķieriem (to sauc arī par alkatīgo dekodēšanu), savukārt, ja top-k ir 3, tas nozīmē, ka nākamais marķieris tiek izvēlēts no 3 visticamākajiem marķieriem (izmantojot temperatūru).",
@@ -188,7 +188,7 @@
"com_endpoint_config_placeholder": "Iestatiet savu atslēgu galvenes izvēlnē, lai izveidotu sarunu.",
"com_endpoint_config_value": "Ievadiet vērtību",
"com_endpoint_context": "Konteksts",
"com_endpoint_context_info": "Maksimālais tokenu skaits, ko var izmantot kontekstam. Izmantojiet to, lai kontrolētu, cik tokenu tiek nosūtīti katrā pieprasījumā. Ja tas nav norādīts, tiks izmantoti sistēmas noklusējuma iestatījumi, pamatojoties uz zināmo modeļu konteksta lielumu. Augstāku vērtību iestatīšana var izraisīt kļūdas un/vai augstākas tokenu izmaksas.",
"com_endpoint_context_info": "Maksimālais tokenu skaits, ko var izmantot kontekstam. Izmanto to, lai kontrolētu, cik tokenu tiek nosūtīti katrā pieprasījumā. Ja tas nav norādīts, tiks izmantoti sistēmas noklusējuma iestatījumi, pamatojoties uz zināmo modeļu konteksta lielumu. Augstāku vērtību iestatīšana var izraisīt kļūdas un/vai augstākas tokenu izmaksas.",
"com_endpoint_context_tokens": "Maksimālais konteksta tokenu skaits",
"com_endpoint_custom_name": "Pielāgots nosaukums",
"com_endpoint_default": "noklusējuma",
@@ -212,7 +212,7 @@
"com_endpoint_google_thinking_budget": "Norāda modeļa izmantoto domāšanas tokenu skaitu. Faktiskais skaits var pārsniegt vai būt mazāks par šo vērtību atkarībā no uzvednes.\n\nŠo iestatījumu atbalsta tikai noteikti modeļi (2.5 sērija). Gemini 2.5 Pro atbalsta 12832 768 žetonus. Gemini 2.5 Flash atbalsta 024 576 žetonus. Gemini 2.5 Flash Lite atbalsta 51224 576 žetonus.\n\nAtstājiet tukšu vai iestatiet uz \"-1\", lai modelis automātiski izlemtu, kad un cik daudz domāt. Pēc noklusējuma Gemini 2.5 Flash Lite nedomā.",
"com_endpoint_google_topk": "Top-k maina to, kā modelis atlasa marķierus izvadei. Ja top-k ir 1, tas nozīmē, ka atlasītais marķieris ir visticamākais starp visiem modeļa vārdu krājumā esošajiem marķieriem (to sauc arī par alkatīgo dekodēšanu), savukārt, ja top-k ir 3, tas nozīmē, ka nākamais marķieris tiek izvēlēts no 3 visticamākajiem marķieriem (izmantojot temperatūru).",
"com_endpoint_google_topp": "`Top-p` maina to, kā modelis atlasa tokenus izvadei. Marķieri tiek atlasīti no K (skatīt parametru topK) ticamākās līdz vismazāk ticamajai, līdz to varbūtību summa ir vienāda ar `top-p` vērtību.",
"com_endpoint_google_use_search_grounding": "Izmantojiet Google meklēšanas pamatošanas funkciju, lai uzlabotu atbildes ar reāllaika tīmekļa meklēšanas rezultātiem. Tas ļauj modeļiem piekļūt aktuālajai informācijai un sniegt precīzākas, aktuālākas atbildes.",
"com_endpoint_google_use_search_grounding": "Izmantot Google meklēšanas pamatošanas funkciju, lai uzlabotu atbildes ar reāllaika tīmekļa meklēšanas rezultātiem. Tas ļauj modeļiem piekļūt aktuālajai informācijai un sniegt precīzākas, aktuālākas atbildes.",
"com_endpoint_instructions_assistants": "Pārrakstīt instrukcijas",
"com_endpoint_instructions_assistants_placeholder": "Pārraksta asistenta norādījumus. Tas ir noderīgi, lai mainītu darbību katrā palaišanas reizē.",
"com_endpoint_max_output_tokens": "Maksimālais izvades tokenu skaits",
@@ -236,14 +236,14 @@
"com_endpoint_openai_stop": "Līdz 4 secībām, kurās API pārtrauks turpmāku tokenu ģenerēšanu.",
"com_endpoint_openai_temp": "Augstākas vērtības = nejaušāks, savukārt zemākas vērtības = fokusētāks un deterministiskāks. Iesakām mainīt šo vai Top P, bet ne abus.",
"com_endpoint_openai_topp": "Alternatīva izlasei ar temperatūru, ko sauc par kodola izlasi, kur modelis ņem vērā tokenu rezultātus ar varbūtības masu top_p. Tātad 0,1 nozīmē, ka tiek ņemti vērā tikai tie tokeni, kas veido augšējo 10% varbūtības masu. Mēs iesakām mainīt šo vai temperatūru, bet ne abus.",
"com_endpoint_openai_use_responses_api": "Izmantojiet Response API sarunas pabeigšanas vietā, kas ietver paplašinātas OpenAI funkcijas. Nepieciešams o1-pro, o3-pro un spriešanas kopsavilkumu iespējošanai.",
"com_endpoint_openai_use_responses_api": "Izmantot Response API sarunas pabeigšanas vietā, kas ietver paplašinātas OpenAI funkcijas. Nepieciešams o1-pro, o3-pro un spriešanas kopsavilkumu iespējošanai.",
"com_endpoint_openai_use_web_search": "Iespējojiet tīmekļa meklēšanas funkcionalitāti, izmantojot OpenAI iebūvētās meklēšanas iespējas. Tas ļauj modelim meklēt tīmeklī aktuālu informāciju un sniegt precīzākas, aktuālākas atbildes.",
"com_endpoint_output": "Izvade",
"com_endpoint_plug_image_detail": "Attēla detaļas",
"com_endpoint_plug_resend_files": "Atkārtoti nosūtīt failus",
"com_endpoint_plug_set_custom_instructions_for_gpt_placeholder": "Iestatiet pielāgotas instrukcijas, kas jāiekļauj sistēmas ziņā. Noklusējuma vērtība: nav",
"com_endpoint_plug_skip_completion": "Izlaist pabeigšanu",
"com_endpoint_plug_use_functions": "Izmant funkcijas",
"com_endpoint_plug_use_functions": "Izmantot funkcijas",
"com_endpoint_presence_penalty": "Klātbūtnes sods",
"com_endpoint_preset": "iepriekš iestatīts",
"com_endpoint_preset_custom_name_placeholder": "kaut kam šeit ir jānotiek. bija tukšs",
@@ -308,15 +308,15 @@
"com_files_table": "kaut kam šeit ir jānotiek. bija tukšs",
"com_generated_files": "Ģenerētie faili:",
"com_hide_examples": "Slēpt piemērus",
"com_info_heic_converting": "HEIC attēla konvertēšana uz JPEG...",
"com_info_heic_converting": "Konvertēju HEIC attēlu uz JPEG...",
"com_nav_2fa": "Divfaktoru autentifikācija (2FA)",
"com_nav_account_settings": "Konta iestatījumi",
"com_nav_always_make_prod": "Vienmēr uzlieciet jaunas versijas produkcijā",
"com_nav_archive_created_at": "Arhivēšanas datums",
"com_nav_archive_name": "Vārds",
"com_nav_archived_chats": "Arhivētas sarunas",
"com_nav_archived_chats": "Arhivētās sarunas",
"com_nav_at_command": "@-Komanda",
"com_nav_at_command_description": "Pārslēgšanas komanda \"@\" galapunktu, modeļu, sākotnējo iestatījumu u. c. pārslēgšanai.",
"com_nav_at_command_description": "Pārslēgšanas komanda \"@\" galapunktu, modeļu, sākotnējo iestatījumu u.c. pārslēgšanai.",
"com_nav_audio_play_error": "Kļūda, atskaņojot audio: {{0}}",
"com_nav_audio_process_error": "Kļūda, apstrādājot audio: {{0}}",
"com_nav_auto_scroll": "Automātiski iet uz jaunāko ziņu, atverot sarunu",
@@ -334,14 +334,14 @@
"com_nav_balance_every": "Katras",
"com_nav_balance_hour": "stunda",
"com_nav_balance_hours": "stundas",
"com_nav_balance_interval": "Intervāls:",
"com_nav_balance_interval": "Atjaunošanas biežums:",
"com_nav_balance_last_refill": "Pēdējā bilances papildišana:",
"com_nav_balance_minute": "minūte",
"com_nav_balance_minutes": "minūtes",
"com_nav_balance_month": "mēnesis",
"com_nav_balance_months": "mēneši",
"com_nav_balance_next_refill": "Nākamā bilances papildināšana:",
"com_nav_balance_next_refill_info": "Nākamā bilances papildināšana notiks automātiski tikai tad, ja būs izpildīti abi nosacījumi: kopš pēdējās bilances papildināšanas ir pagājis norādītais laika intervāls un uzaicinājuma nosūtīšana izraisītu jūsu atlikuma samazināšanos zem nulles.",
"com_nav_balance_next_refill_info": "Nākamā bilances papildināšana notiks automātiski tikai tad, ja būs izpildīti abi nosacījumi: kopš pēdējās bilances papildināšanas ir pagājis norādītais laika atjaunošanas biežums un uzaicinājuma nosūtīšana izraisītu jūsu atlikuma samazināšanos zem nulles.",
"com_nav_balance_refill_amount": "Bilances papildināšanas apjoms:",
"com_nav_balance_second": "otrais",
"com_nav_balance_seconds": "sekundes",
@@ -353,10 +353,10 @@
"com_nav_chat_commands": "Sarunu komandas",
"com_nav_chat_commands_info": "Šīs komandas tiek aktivizētas, ierakstot noteiktas rakstzīmes ziņas sākumā. Katru komandu aktivizē tai norādītais prefikss. Varat tās atspējot, ja bieži izmantojat šīs rakstzīmes ziņojumu sākumā.",
"com_nav_chat_direction": "Sarunas virziens",
"com_nav_clear_all_chats": "Notīrīt visas sarunas",
"com_nav_clear_all_chats": "Dzēst visas saglabātās sarunas",
"com_nav_clear_cache_confirm_message": "Vai tiešām vēlaties notīrīt kešatmiņu?",
"com_nav_clear_conversation": "Skaidras sarunas",
"com_nav_clear_conversation_confirm_message": "Vai tiešām vēlaties notīrīt visas sarunas? Šī darbība ir neatgriezeniska.",
"com_nav_clear_conversation_confirm_message": "Vai tiešām vēlaties dzēst visas saglabātās sarunas? Šī darbība ir neatgriezeniska.",
"com_nav_close_sidebar": "Aizvērt sānu joslu",
"com_nav_commands": "Komandas",
"com_nav_confirm_clear": "Apstiprināt dzēšanu",
@@ -371,7 +371,7 @@
"com_nav_delete_data_info": "Visi jūsu dati tiks dzēsti.",
"com_nav_delete_warning": "BRĪDINĀJUMS: Tas neatgriezeniski izdzēsīs jūsu kontu.",
"com_nav_enable_cache_tts": "Iespējot kešatmiņu TTS",
"com_nav_enable_cloud_browser_voice": "Izmantojiet cloud-based balsis",
"com_nav_enable_cloud_browser_voice": "Izmantot mākonī bāzētas balsis",
"com_nav_enabled": "Iespējots",
"com_nav_engine": "Dzinējs",
"com_nav_enter_to_send": "Nospiediet taustiņu Enter, lai nosūtītu ziņas",
@@ -392,7 +392,7 @@
"com_nav_font_size_xl": "Īpaši liels",
"com_nav_font_size_xs": "Īpaši mazs",
"com_nav_help_faq": "Palīdzība un bieži uzdotie jautājumi",
"com_nav_hide_panel": "Slēpt labās malējās sānu paneli",
"com_nav_hide_panel": "Slēpt labo sāna paneli",
"com_nav_info_balance": "Bilance parāda, cik daudz tokenu kredītu jums ir atlicis izmantot. Tokenu kredīti tiek pārvērsti naudas vērtībā (piemēram, 1000 kredīti = 0,001 USD).",
"com_nav_info_code_artifacts": "Iespējo eksperimentāla koda artefaktu rādīšanu blakus sarunai",
"com_nav_info_code_artifacts_agent": "Iespējo koda artefaktu izmantošanu šim aģentam. Pēc noklusējuma tiek pievienotas papildu instrukcijas, kas attiecas uz artefaktu izmantošanu, ja vien nav iespējots \"Pielāgots uzvednes režīms\".",
@@ -445,7 +445,7 @@
"com_nav_latex_parsing": "LaTeX parsēšana ziņās (var ietekmēt veiktspēju)",
"com_nav_log_out": "Izrakstīties",
"com_nav_long_audio_warning": "Garāku tekstu apstrāde prasīs ilgāku laiku.",
"com_nav_maximize_chat_space": "Maksimāli izmantojiet sarunas telpu",
"com_nav_maximize_chat_space": "Maksimāli izmantot sarunas telpas izmērus",
"com_nav_mcp_configure_server": "Konfigurēt {{0}}",
"com_nav_mcp_status_connecting": "{{0}} - Savienojas",
"com_nav_mcp_vars_update_error": "Kļūda, atjauninot MCP pielāgotos lietotāja parametrus: {{0}}",
@@ -465,7 +465,7 @@
"com_nav_profile_picture": "Profila attēls",
"com_nav_save_badges_state": "Saglabāt nozīmīšu stāvokli",
"com_nav_save_drafts": "Saglabāt melnrakstus lokāli",
"com_nav_scroll_button": "Ritiniet līdz beigu pogai",
"com_nav_scroll_button": "Pāriet uz pēdējo ierakstu poga",
"com_nav_search_placeholder": "Meklēt ziņas",
"com_nav_send_message": "Sūtīt ziņu",
"com_nav_setting_account": "Konts",
@@ -481,10 +481,10 @@
"com_nav_show_code": "Vienmēr rādīt kodu, izmantojot koda interpretētāju",
"com_nav_show_thinking": "Pēc noklusējuma atvērt domāšanas nolaižamos sarakstus",
"com_nav_slash_command": "/-Komanda",
"com_nav_slash_command_description": "Pārslēgt komandu \"/\", lai atlasītu uzvedni, izmantojot tastatūru",
"com_nav_speech_to_text": "Runas pārvēršana tekstā",
"com_nav_slash_command_description": "Ieslēgt komandu \"/\", lai atlasītu uzvedni izmantojot tastatūru",
"com_nav_speech_to_text": "Balss pārvēršana tekstā",
"com_nav_stop_generating": "Pārtraukt ģenerēšanu",
"com_nav_text_to_speech": "Teksts runā",
"com_nav_text_to_speech": "Teksta pārvēršana balsī",
"com_nav_theme": "Tēma",
"com_nav_theme_dark": "Tumšs",
"com_nav_theme_light": "Gaišs",
@@ -554,7 +554,6 @@
"com_ui_agent_var": "{{0}} aģents",
"com_ui_agent_version": "Versija",
"com_ui_agent_version_active": "Aktīvā versija",
"com_ui_agent_version_duplicate": "Atrasta dublikāta versija. Šī darbība izveidotu versiju, kas ir identiska citai, jau esošai versijai. {{versionIndex}}.",
"com_ui_agent_version_empty": "Nav pieejamu versiju",
"com_ui_agent_version_error": "Kļūda, ielādējot versijas",
"com_ui_agent_version_history": "Versiju vēsture",
@@ -717,13 +716,13 @@
"com_ui_delete_tool": "Dzēst rīku",
"com_ui_delete_tool_confirm": "Vai tiešām vēlaties dzēst šo rīku?",
"com_ui_deleted": "Dzēsts",
"com_ui_deleting_file": "Tiek dzēsts fails...",
"com_ui_deleting_file": "Dzēšu failu...",
"com_ui_descending": "Dilstošs",
"com_ui_description": "Apraksts",
"com_ui_description_placeholder": "Pēc izvēles: ievadiet aprakstu, kas jāparāda uzvednē",
"com_ui_deselect_all": "Noņemt atlasi visam",
"com_ui_detailed": "Detalizēta",
"com_ui_disabling": "Atspējošana...",
"com_ui_disabling": "Atspējo...",
"com_ui_download": "Lejupielādēt",
"com_ui_download_artifact": "Lejupielādēt artefaktu",
"com_ui_download_backup": "Lejupielādēt rezerves kodus",
@@ -734,7 +733,7 @@
"com_ui_dropdown_variables_info": "Izveidojiet pielāgotas nolaižamās izvēlnes savām uzvednēm:{{variable_name:option1|option2|option3}}`",
"com_ui_duplicate": "Dublikāts",
"com_ui_duplication_error": "Sarunas dublēšanas laikā radās kļūda.",
"com_ui_duplication_processing": "Sarunas dublēšana...",
"com_ui_duplication_processing": "Dublēju sarunu...",
"com_ui_duplication_success": "Saruna veiksmīgi dublēta",
"com_ui_edit": "Rediģēt",
"com_ui_edit_editing_image": "Attēla rediģēšana",
@@ -799,7 +798,7 @@
"com_ui_fork_info_visible": "Šī opcija atzaro tikai redzamās ziņas; citiem vārdiem sakot, tiešo ceļu uz mērķa ziņām bez atzariem.",
"com_ui_fork_more_details_about": "Skatiet papildu informāciju un detaļas par \"{{0}}\" atzarojuma variantu",
"com_ui_fork_more_info_options": "Skatiet detalizētu visu atzarojuma opciju un to darbības skaidrojumu",
"com_ui_fork_processing": "Sarunas atzarošana...",
"com_ui_fork_processing": "Atzaroju sarunu...",
"com_ui_fork_remember": "Atcerēties",
"com_ui_fork_remember_checked": "Jūsu izvēle tiks atcerēta pēc lietošanas. To var jebkurā laikā mainīt iestatījumos.",
"com_ui_fork_split_target": "Sāciet atzarošanu šeit",
@@ -808,7 +807,7 @@
"com_ui_fork_visible": "Tikai redzamās ziņas",
"com_ui_generate_backup": "Ģenerēt rezerves kodus",
"com_ui_generate_qrcode": "Ģenerēt QR kodu",
"com_ui_generating": "Notiek ģenerēšana...",
"com_ui_generating": "Ģenerē...",
"com_ui_generation_settings": "Ģenerēšanas iestatījumi",
"com_ui_getting_started": "Darba sākšana",
"com_ui_global_group": "kaut kam šeit ir jānotiek. bija tukšs",
@@ -944,13 +943,13 @@
"com_ui_provider": "Pakalpojumu sniedzējs",
"com_ui_quality": "Kvalitāte",
"com_ui_read_aloud": "Lasīt skaļi",
"com_ui_redirecting_to_provider": "Pāradresācija uz {{0}}, lūdzu, uzgaidiet...",
"com_ui_reference_saved_memories": "Atsauces uz saglabātajām atmiņām",
"com_ui_reference_saved_memories_description": "Ļaujiet asistentam atsaukties uz jūsu saglabātajām atmiņām un izmantot tās, atbildot",
"com_ui_redirecting_to_provider": "Pārvirzu uz {{0}}, lūdzu, uzgaidiet...",
"com_ui_reference_saved_memories": "References uz saglabātajām atmiņām",
"com_ui_reference_saved_memories_description": "Ļaut asistentam atsaukties uz jūsu saglabātajām atmiņām un izmantot tās atbildot",
"com_ui_refresh_link": "Atsvaidzināt saiti",
"com_ui_regenerate": "Atjaunot",
"com_ui_regenerate_backup": "Atjaunot rezerves kodus",
"com_ui_regenerating": "Atjaunošanās...",
"com_ui_regenerating": "Atjaunojas...",
"com_ui_region": "Reģions",
"com_ui_reinitialize": "Reinicializēt",
"com_ui_rename": "Pārdēvēt",
@@ -962,7 +961,7 @@
"com_ui_reset_zoom": "Atiestatīt tālummaiņu",
"com_ui_result": "Rezultāts",
"com_ui_revoke": "Atsaukt",
"com_ui_revoke_info": "Atsaukt visus lietotāja sniegtos kredenciāļu datus",
"com_ui_revoke_info": "Atcelt visus lietotāja sniegtos lietotāja datus",
"com_ui_revoke_key_confirm": "Vai tiešām vēlaties atsaukt šo atslēgu?",
"com_ui_revoke_key_endpoint": "Atsaukt atslēgu priekš {{0}}",
"com_ui_revoke_keys": "Atsaukt atslēgas",
@@ -1030,7 +1029,7 @@
"com_ui_temporary": "Pagaidu saruna",
"com_ui_terms_and_conditions": "Noteikumi un nosacījumi",
"com_ui_terms_of_service": "Pakalpojumu sniegšanas noteikumi",
"com_ui_thinking": "Domājot...",
"com_ui_thinking": "Domā...",
"com_ui_thoughts": "Domas",
"com_ui_token": "tokens",
"com_ui_token_exchange_method": "Tokenu apmaiņas metode",
@@ -1072,7 +1071,7 @@
"com_ui_used": "Lietots",
"com_ui_value": "Vērtība",
"com_ui_variables": "Mainīgie",
"com_ui_variables_info": "Mainīgo veidošanai tekstā izmantojiet dubultās iekavas, piemēram, `{{example variable}}`, lai vēlāk aizpildītu, izmantojot uzvedni.",
"com_ui_variables_info": "Mainīgo veidošanai tekstā izmantot dubultās iekavas, piemēram, `{{example variable}}`, lai vēlāk aizpildītu, izmantojot uzvedni.",
"com_ui_verify": "Pārbaudīt",
"com_ui_version_var": "Versija {{0}}",
"com_ui_versions": "Versijas",

View File

@@ -221,6 +221,7 @@
"com_endpoint_prompt_prefix_assistants": "Ytterligare instruktioner",
"com_endpoint_prompt_prefix_placeholder": "Ange anpassade instruktioner eller kontext. Ignoreras om tom.",
"com_endpoint_save_as_preset": "Spara som förinställning",
"com_endpoint_search": "Sök slutpunkt efter namn",
"com_endpoint_search_endpoint_models": "Sök {{0}} modeller...",
"com_endpoint_search_models": "Sök modeller...",
"com_endpoint_search_var": "Sök {{0}}...",

View File

@@ -1,4 +1,6 @@
{
"chat_direction_left_to_right": "ตรงนี้ต้องมีอะไรสักอย่าง แต่ตอนนี้ยังไม่มี",
"chat_direction_right_to_left": "ตรงนี้ต้องมีอะไรสักอย่าง แต่ตอนนี้ยังไม่มี",
"com_a11y_ai_composing": "AI กำลังเรียบเรียงข้อความ",
"com_a11y_end": "AI ตอบคำถามเสร็จสิ้นแล้ว",
"com_a11y_start": "AI เริ่มต้นตอบคำถามแล้ว",
@@ -9,13 +11,24 @@
"com_agents_create_error": "เกิดข้อผิดพลาดในการสร้างเอเจนต์ของคุณ",
"com_agents_description_placeholder": "ตัวเลือกเพิ่มเติม: อธิบายเอเจนต์ของคุณที่นี่",
"com_agents_enable_file_search": "เปิดใช้งานการค้นหาไฟล์",
"com_agents_file_context": "ข้อความจากไฟล์ (OCR)",
"com_agents_file_context_disabled": "ต้องสร้าง เอเจนท์ ก่อนอัปโหลดไฟล์",
"com_agents_file_context_info": "ไฟล์ที่อัปโหลดเป็น “Context” (บริบท) จะถูกประมวลผลด้วยเทคโนโลยี OCR เพื่อดึงข้อความออกมา แล้วนำไปเพิ่มในคำสั่งของเอเจนต์ เหมาะอย่างยิ่งสำหรับเอกสาร ภาพที่มีข้อความ หรือไฟล์ PDF ที่คุณต้องการข้อความทั้งหมดของไฟล์",
"com_agents_file_search_disabled": "ต้องสร้างเอเจนต์ก่อนที่จะอัปโหลดไฟล์สำหรับใช้ในการค้นหาไฟล์",
"com_agents_file_search_info": "เมื่อเปิดใช้งาน เอเจนต์จะได้รับข้อมูลเกี่ยวกับชื่อไฟล์ที่ระบุไว้ด้านล่างอย่างถูกต้อง ทำให้สามารถดึงข้อมูลที่เกี่ยวข้องจากไฟล์เหล่านี้ได้",
"com_agents_instructions_placeholder": "คำสั่งของระบบที่เอเจนต์ใช้งาน",
"com_agents_mcp_description_placeholder": "อธิบายการทำงานสั้นๆ",
"com_agents_mcp_icon_size": "ขนาดขั้นต่ำคือ 128 x 128 px",
"com_agents_mcp_info": "เพิ่ม MCP servers ให้เอเจนต์ของคุณ เพื่อให้สามารถปฏิบัติภารกิจและเชื่อมต่อกับบริการภายนอกได้",
"com_agents_mcp_name_placeholder": "เครื่องมือที่สร้างเอง",
"com_agents_mcp_trust_subtext": "LibreChat ไม่ตรวจสอบ หรือรับรองตัวเชื่อมต่อที่สร้างขึ้นเอง",
"com_agents_mcps_disabled": "ต้องสร้างเอเจนต์ก่อนเพิ่ม MCP servers",
"com_agents_missing_provider_model": "โปรดเลือกผู้ให้บริการและโมเดลก่อนสร้างเอเจนต์",
"com_agents_name_placeholder": "ตัวเลือกเพิ่มเติม: ชื่อของเอเจนต์",
"com_agents_no_access": "คุณไม่มีสิทธิ์แก้ไขเอเจนต์นี้",
"com_agents_no_agent_id_error": "ไม่พบรหัสเอเจนต์ (Agent ID) กรุณาสร้างเอเจนต์ก่อน",
"com_agents_not_available": "ไม่มีเอเจนต์ให้บริการ",
"com_agents_search_info": "เมื่อเปิดใช้งานแล้ว เอเจนต์องคุณจะสามารถค้นหาข้อมูลล่าสุดบนเว็บได้ ต้องมีรหัส API ที่ถูกต้อง",
"com_agents_search_name": "ค้นหาเอเจนต์ตามชื่อ",
"com_agents_update_error": "เกิดข้อผิดพลาดในการอัปเดตเอเจนต์ของคุณ",
"com_assistants_action_attempt": "ผู้ช่วยต้องการสนทนากับ {{0}}",
@@ -56,6 +69,7 @@
"com_assistants_non_retrieval_model": "การค้นหาไฟล์ไม่ได้เปิดใช้งานในโมเดลนี้ โปรดเลือกโมเดลอื่น",
"com_assistants_retrieval": "การดึงข้อมูล",
"com_assistants_running_action": "กำลังดำเนินการ",
"com_assistants_running_var": "กำลังดำเนินการ {{0}}",
"com_assistants_search_name": "ค้นหาผู้ช่วยตามชื่อ",
"com_assistants_update_actions_error": "เกิดข้อผิดพลาดในการสร้างหรืออัปเดตการดำเนินการ",
"com_assistants_update_actions_success": "สร้างหรืออัปเดตการดำเนินการสำเร็จแล้ว",
@@ -117,6 +131,7 @@
"com_auth_reset_password_if_email_exists": "หากมีบัญชีที่ใช้อีเมลนั้น ระบบได้ส่งอีเมลพร้อมคำแนะนำในการรีเซ็ตรหัสผ่านแล้ว โปรดตรวจสอบโฟลเดอร์สแปมของคุณด้วย",
"com_auth_reset_password_link_sent": "ส่งอีเมลแล้ว",
"com_auth_reset_password_success": "รีเซ็ตรหัสผ่านสำเร็จ",
"com_auth_saml_login": "ดำเนินการต่อด้วย SAML",
"com_auth_sign_in": "เข้าสู่ระบบ",
"com_auth_sign_up": "ลงทะเบียน",
"com_auth_submit_registration": "ส่งการลงทะเบียน",
@@ -128,6 +143,8 @@
"com_auth_username_min_length": "ชื่อผู้ใช้ต้องมีอย่างน้อย 2 ตัวอักษร",
"com_auth_verify_your_identity": "ยืนยันตัวตนของคุณ",
"com_auth_welcome_back": "ยินดีต้อนรับกลับ",
"com_citation_more_details": "รายละเอียดเพิ่มเติมเกี่ยวกับ {{label}}",
"com_citation_source": "แหล่งที่มา",
"com_click_to_download": "(คลิกที่นี่เพื่อดาวน์โหลด)",
"com_download_expired": "(การดาวน์โหลดหมดอายุแล้ว)",
"com_download_expires": "(คลิกที่นี่เพื่อดาวน์โหลด - หมดอายุ {{0}})",
@@ -143,6 +160,7 @@
"com_endpoint_anthropic_thinking_budget": "กำหนดจำนวนโทเค็นสูงสุดที่ Claude สามารถใช้สำหรับกระบวนการคิดวิเคราะห์ภายใน งบประมาณที่สูงขึ้นสามารถปรับปรุงคุณภาพการตอบสนองโดยช่วยให้วิเคราะห์ปัญหาที่ซับซ้อนได้อย่างละเอียดมากขึ้น แม้ว่า Claude อาจไม่ใช้งบประมาณทั้งหมดที่จัดสรร โดยเฉพาะในช่วงเกิน 32K การตั้งค่านี้ต้องต่ำกว่า \"โทเค็นเอาต์พุตสูงสุด\"",
"com_endpoint_anthropic_topk": "Top-k เปลี่ยนวิธีที่โมเดลเลือกโทเค็นสำหรับเอาต์พุต top-k เท่ากับ 1 หมายความว่าโทเค็นที่เลือกมีความน่าจะเป็นมากที่สุดในบรรดาโทเค็นทั้งหมดในคำศัพท์ของโมเดล (เรียกอีกอย่างว่าการถอดรหัสแบบโลภ) ในขณะที่ top-k เท่ากับ 3 หมายความว่าโทเค็นถัดไปจะถูกเลือกจากโทเค็นที่มีความน่าจะเป็นสูงสุด 3 อันดับแรก (โดยใช้อุณหภูมิ)",
"com_endpoint_anthropic_topp": "Top-p เปลี่ยนวิธีที่โมเดลเลือกโทเค็นสำหรับเอาต์พุต โทเค็นจะถูกเลือกจากโทเค็นที่มีความน่าจะเป็นมากที่สุด K ตัว (ดูพารามิเตอร์ topK) ไปจนถึงน้อยที่สุดจนกว่าผลรวมของความน่าจะเป็นจะเท่ากับค่า top-p",
"com_endpoint_anthropic_use_web_search": "เปิดใช้งานฟังก์ชันการค้นหาบนเว็บโดยใช้ฟังก์ชันการค้นหาของ Anthropic ซึ่งช่วยให้โมเดลสามารถค้นหาข้อมูลล่าสุดบนเว็บและให้คำตอบที่แม่นยำและเป็นอัปเดต",
"com_endpoint_assistant": "ผู้ช่วย",
"com_endpoint_assistant_model": "โมเดลผู้ช่วย",
"com_endpoint_assistant_placeholder": "โปรดเลือกผู้ช่วยจากแผงด้านขวามือ",
@@ -177,6 +195,11 @@
"com_endpoint_default_blank": "ค่าเริ่มต้น: ว่างเปล่า",
"com_endpoint_default_empty": "ค่าเริ่มต้น: ว่างเปล่า",
"com_endpoint_default_with_num": "ค่าเริ่มต้น: {{0}}",
"com_endpoint_deprecated": "เลิกใช้งาน",
"com_endpoint_deprecated_info": "endpoint นี้เลิกใช้งานแล้วและอาจถูกนำออกในเวอร์ชันถัดไป โปรดใช้ agent endpoint แทน",
"com_endpoint_deprecated_info_a11y": "plugin endpoint นี้เลิกใช้งานแล้วและอาจถูกนำออกในเวอร์ชันถัดไป โปรดใช้ agent endpoint แทน",
"com_endpoint_disable_streaming": "ปิดใช้งานการตอบกลับแบบสตรีมมิ่งและรับการตอบกลับแบบครั้งเดียว มีประโยชน์สำหรับโมเดลอย่าง o3 ที่ต้องมีการยืนยันตัวตนสำหรับการสตรีมมิ่ง",
"com_endpoint_disable_streaming_label": "ปิดใช้งานการตอบแบบสตรีมมิ่ง",
"com_endpoint_examples": "ค่าที่กำหนดไว้ล่วงหน้า",
"com_endpoint_export": "ส่งออก",
"com_endpoint_export_share": "ส่งออก/แชร์",
@@ -185,6 +208,7 @@
"com_endpoint_google_custom_name_placeholder": "ตั้งชื่อที่กำหนดเองสำหรับ Google",
"com_endpoint_google_maxoutputtokens": "จำนวนโทเค็นสูงสุดที่สามารถสร้างในการตอบสนอง ระบุค่าที่ต่ำกว่าสำหรับการตอบสนองที่สั้นกว่าและค่าที่สูงกว่าสำหรับการตอบสนองที่ยาวกว่า หมายเหตุ: โมเดลอาจหยุดก่อนถึงขีดจำกัดนี้",
"com_endpoint_google_temp": "ค่าที่สูงขึ้น = สุ่มมากขึ้น ในขณะที่ค่าที่ต่ำกว่า = มีจุดเน้นมากขึ้นและแน่นอนมากขึ้น เราแนะนำให้เปลี่ยนค่านี้หรือ Top P แต่ไม่ใช่ทั้งสอง",
"com_endpoint_google_thinking": "เปิดใช้งานหรือปิดใช้งานการใช้เหตุผล การตั้งค่านี้รองรับเฉพาะบางโมเดล (ซีรีส์ 2.5) เท่านั้น สำหรับรุ่นเก่า การตั้งค่านี้อาจไม่มีผล",
"com_endpoint_google_topk": "Top-k เปลี่ยนวิธีที่โมเดลเลือกโทเค็นสำหรับเอาต์พุต top-k เท่ากับ 1 หมายความว่าโทเค็นที่เลือกมีความน่าจะเป็นมากที่สุดในบรรดาโทเค็นทั้งหมดในคำศัพท์ของโมเดล (เรียกอีกอย่างว่าการถอดรหัสแบบโลภ) ในขณะที่ top-k เท่ากับ 3 หมายความว่าโทเค็นถัดไปจะถูกเลือกจากโทเค็นที่มีความน่าจะเป็น 3 อันดับแรก (ใช้อุณหภูมิ)",
"com_endpoint_google_topp": "Top-p เปลี่ยนวิธีที่โมเดลเลือกโทเค็นสำหรับเอาต์พุต โทเค็นถูกเลือกจากมากที่สุด K (ดูพารามิเตอร์ topK) ที่เป็นไปได้ไปจนถึงน้อยที่สุดจนกว่าผลรวมของความน่าจะเป็นจะเท่ากับค่า top-p",
"com_endpoint_instructions_assistants": "ข้ามคำแนะนำ",

View File

@@ -228,7 +228,7 @@
"com_endpoint_openai_max": "最大生成词元数。输入词元长度由模型的上下文长度决定。",
"com_endpoint_openai_max_tokens": "可选的 'max_tokens' 字段,表示在对话补全中可生成的最大词元数量。输入词元和生成词元的总长度受模型上下文长度的限制。如果该数值超过最大上下文词元数,您可能会遇到错误。",
"com_endpoint_openai_pres": "值介于 -2.0 到 2.0 之间。正值将惩罚当前已经使用的词元,从而增加讨论新话题的可能性。",
"com_endpoint_openai_prompt_prefix_placeholder": "在系统消息中添加自定义指令,默认为空",
"com_endpoint_openai_prompt_prefix_placeholder": "设置自定义指令以包含在系统消息中,默认为空",
"com_endpoint_openai_reasoning_effort": "仅限 o1 和 o3 模型:限制推理模型的推理工作量。减少推理工作量可以获取更快的响应并在响应中使用更少的词元进行推理。",
"com_endpoint_openai_reasoning_summary": "仅限 Responses API模型执行推理的摘要。这对于调试和理解模型的推理过程非常有帮助。可以设置为无、自动、简洁或详细。",
"com_endpoint_openai_resend": "重新发送所有先前附加的图片。注意:这会显着增加词元成本,并且可能会遇到很多关于图片附件的错误。",
@@ -241,7 +241,7 @@
"com_endpoint_output": "输出",
"com_endpoint_plug_image_detail": "图片细节",
"com_endpoint_plug_resend_files": "重发文件",
"com_endpoint_plug_set_custom_instructions_for_gpt_placeholder": "在消息开头添加系统级提示词,默认为空",
"com_endpoint_plug_set_custom_instructions_for_gpt_placeholder": "设置自定义指令以包含在系统消息中,默认为空",
"com_endpoint_plug_skip_completion": "跳过补全",
"com_endpoint_plug_use_functions": "使用函数",
"com_endpoint_presence_penalty": "话题新鲜度",
@@ -554,7 +554,6 @@
"com_ui_agent_var": "{{0}} 智能体",
"com_ui_agent_version": "版本",
"com_ui_agent_version_active": "活动版本",
"com_ui_agent_version_duplicate": "检测到重复版本。这将创建与版本 {{versionIndex}} 完全相同的版本。",
"com_ui_agent_version_empty": "无可用版本",
"com_ui_agent_version_error": "获取版本时发生错误",
"com_ui_agent_version_history": "版本历史",

View File

@@ -313,20 +313,30 @@
background-color: transparent; /* Color of the tracking area */
}
.sp-preview-container {
@apply flex h-full w-full grow flex-col justify-center;
}
.sp-preview {
@apply flex h-full w-full grow flex-col justify-center;
}
.sp-preview-iframe {
@apply grow;
}
/* Base wrapper for both preview and editor */
.sp-wrapper {
@apply flex h-full w-full grow flex-col justify-center;
@apply flex h-full w-full grow flex-col;
}
/* Stack containers (sp-preview and sp-editor) */
.sp-preview,
.sp-editor {
@apply flex h-full w-full grow flex-col;
}
/* Inner containers */
.sp-preview-container,
.sp-code-editor {
@apply flex h-full w-full grow flex-col;
}
/* Content elements */
.sp-preview-iframe {
@apply h-full w-full grow;
}
.sp-cm {
@apply h-full w-full grow;
}
@keyframes shake {

View File

@@ -60,6 +60,7 @@
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--markdown-font-size: 1rem;
}
html {
--brand-purple: #ab68ff;

View File

@@ -7,7 +7,7 @@
### ⚠️ Warning:
This script can be expensive, several dollars worth, even with prompt caching. It can also be slow if has not been ran in a while, with translations contributed.
This script can be expensive, several dollars worth, even with prompt caching. It can also be slow if it has not been run in a while, with translations contributed.
### Instructions:

View File

@@ -1,3 +1,3 @@
// v0.8.0-rc1
// v0.8.0-rc2
// See .env.test.example for an example of the '.env.test' file.
require('dotenv').config({ path: './e2e/.env.test' });

View File

@@ -1,6 +1,7 @@
apiVersion: v2
name: librechat
description: A Helm chart for LibreChat
icon: https://www.librechat.ai/librechat_alt.svg
# A chart can be either an 'application' or a 'library' chart.
#
@@ -14,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.8.9
version: 1.8.10
# 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
@@ -22,7 +23,7 @@ version: 1.8.9
# It is recommended to use it with quotes.
# renovate: image=ghcr.io/danny-avila/librechat
appVersion: "v0.8.0-rc1"
appVersion: "v0.8.0-rc2"
home: https://www.librechat.ai

View File

@@ -20,7 +20,7 @@ librechat:
configEnv:
PLUGIN_MODELS: gpt-4,gpt-4-turbo-preview,gpt-4-0125-preview,gpt-4-1106-preview,gpt-4-0613,gpt-3.5-turbo,gpt-3.5-turbo-0125,gpt-3.5-turbo-1106,gpt-3.5-turbo-0613
DEBUG_PLUGINS: "true"
# IMPORTANT -- GENERATE your own: openssl rand -hex 32 and openssl rand -hex 16 for CREDS_IV. Best Practise: Put into Secret. See gloobal.librechat.existingSecretName
# IMPORTANT -- GENERATE your own: openssl rand -hex 32 and openssl rand -hex 16 for CREDS_IV. Best Practise: Put into Secret. See global.librechat.existingSecretName
CREDS_KEY: 9e95d9894da7e68dd69c0046caf5343c8b1e80c89609b5a1e40e6568b5b23ce6
CREDS_IV: ac028c86ba23f4cd48165e0ca9f2c683
JWT_SECRET: 16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef
@@ -231,4 +231,4 @@ meilisearch:
tag: "v1.7.3"
auth:
# Use an existing Kubernetes secret for the MEILI_MASTER_KEY
existingMasterKeySecret: "librechat-credentials-env"
existingMasterKeySecret: "librechat-credentials-env"

View File

@@ -1,28 +0,0 @@
apiVersion: v1
entries:
librechat:
- apiVersion: v2
appVersion: v0.8.0-rc1
created: "2025-08-04T23:43:12.551019369Z"
dependencies:
- condition: mongodb.enabled
name: mongodb
repository: https://charts.bitnami.com/bitnami
version: 16.3.0
- condition: meilisearch.enabled
name: meilisearch
repository: https://meilisearch.github.io/meilisearch-kubernetes
version: 0.11.0
- condition: librechat-rag-api.enabled
name: librechat-rag-api
repository: file://../librechat-rag-api
version: 0.5.2
description: A Helm chart for LibreChat
digest: 8e3b43317118a201661ed8c649705c6a3e57c887173a79f497df842120e6e7ed
home: https://www.librechat.ai
name: librechat
type: application
urls:
- https://github.com/danny-avila/LibreChat/releases/download/librechat-1.8.9/librechat-1.8.9.tgz
version: 1.8.9
generated: "2025-08-04T23:43:12.551028476Z"

91
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "LibreChat",
"version": "v0.8.0-rc1",
"version": "v0.8.0-rc2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "LibreChat",
"version": "v0.8.0-rc1",
"version": "v0.8.0-rc2",
"license": "ISC",
"workspaces": [
"api",
@@ -47,7 +47,7 @@
},
"api": {
"name": "@librechat/backend",
"version": "v0.8.0-rc1",
"version": "v0.8.0-rc2",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0",
@@ -65,7 +65,7 @@
"@langchain/google-vertexai": "^0.2.13",
"@langchain/openai": "^0.5.18",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.69",
"@librechat/agents": "^2.4.75",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.17.1",
@@ -2236,20 +2236,6 @@
"node": ">= 0.8.0"
}
},
"api/node_modules/express-rate-limit": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.4.1.tgz",
"integrity": "sha512-KS3efpnpIDVIXopMc65EMbWbUht7qvTCdtCR2dD/IZmi9MIkopYESwyRqLgv8Pfu589+KqDqOdzJWW7AHoACeg==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": "4 || 5 || ^5.0.0-beta.1"
}
},
"api/node_modules/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
@@ -2510,38 +2496,6 @@
}
}
},
"api/node_modules/mongodb-connection-string-url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz",
"integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==",
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^13.0.0"
}
},
"api/node_modules/mongodb-connection-string-url/node_modules/tr46": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
"dependencies": {
"punycode": "^2.3.0"
},
"engines": {
"node": ">=14"
}
},
"api/node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz",
"integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==",
"dependencies": {
"tr46": "^4.1.1",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=16"
}
},
"api/node_modules/mongoose": {
"version": "8.12.1",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.12.1.tgz",
@@ -2735,7 +2689,7 @@
},
"client": {
"name": "@librechat/frontend",
"version": "v0.8.0-rc1",
"version": "v0.8.0-rc2",
"license": "ISC",
"dependencies": {
"@ariakit/react": "^0.4.15",
@@ -20124,9 +20078,9 @@
}
},
"node_modules/@langchain/anthropic": {
"version": "0.3.24",
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-0.3.24.tgz",
"integrity": "sha512-Gi1TwXu5vkCxUMToiXaiwTTWq9v3WMyU3ldB/VEWjzbkr3nKF5kcp+HLqhvV7WWOFVTTNgG+pzfq8JALecq5MA==",
"version": "0.3.26",
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-0.3.26.tgz",
"integrity": "sha512-IRCjkxsMx6MZUZmv/aYX5A9RdIduzdR0eeOc4rX8waBcYP7qmtA/CUTNmTtMSoXfOfJY4s3414bkVNBkmS0+5g==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.56.0",
@@ -21573,12 +21527,12 @@
}
},
"node_modules/@librechat/agents": {
"version": "2.4.69",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.69.tgz",
"integrity": "sha512-Yt0rttqOaZQeZPIB68I8RdnU6SHeh0OJV5yEg8mx9EHTA7SnV/lOlDhn424aXdpMvYZYuxAt/Fev3jTC7qKiTg==",
"version": "2.4.75",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.75.tgz",
"integrity": "sha512-GueaA5WAc0nliuQjqbqBVAR/7/qaFw8xpg5ClaFHbm5YseyKF+iuSg+sBaF0eo2ceswO3nEmdLa3QtIhKXsQgg==",
"license": "MIT",
"dependencies": {
"@langchain/anthropic": "^0.3.24",
"@langchain/anthropic": "^0.3.26",
"@langchain/aws": "^0.1.12",
"@langchain/community": "^0.3.47",
"@langchain/core": "^0.3.62",
@@ -29450,7 +29404,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true
"devOptional": true
},
"node_modules/@types/unist": {
"version": "2.0.10",
@@ -33161,6 +33115,16 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -51381,7 +51345,7 @@
},
"packages/api": {
"name": "@librechat/api",
"version": "1.2.9",
"version": "1.3.0",
"license": "ISC",
"devDependencies": {
"@babel/preset-env": "^7.21.5",
@@ -51414,7 +51378,7 @@
},
"peerDependencies": {
"@langchain/core": "^0.3.62",
"@librechat/agents": "^2.4.69",
"@librechat/agents": "^2.4.75",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.17.1",
"axios": "^1.8.2",
@@ -51507,7 +51471,7 @@
},
"packages/client": {
"name": "@librechat/client",
"version": "0.2.3",
"version": "0.2.4",
"devDependencies": {
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^25.0.2",
@@ -51560,6 +51524,7 @@
"@tanstack/react-virtual": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.2.6",
"framer-motion": "^12.23.6",
"i18next": "^24.2.2 || ^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
@@ -51805,7 +51770,7 @@
},
"packages/data-provider": {
"name": "librechat-data-provider",
"version": "0.7.903",
"version": "0.8.001",
"license": "ISC",
"dependencies": {
"axios": "^1.8.2",

View File

@@ -1,6 +1,6 @@
{
"name": "LibreChat",
"version": "v0.8.0-rc1",
"version": "v0.8.0-rc2",
"description": "",
"workspaces": [
"api",

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/api",
"version": "1.2.9",
"version": "1.3.0",
"type": "commonjs",
"description": "MCP services for LibreChat",
"main": "dist/index.js",
@@ -70,7 +70,7 @@
},
"peerDependencies": {
"@langchain/core": "^0.3.62",
"@librechat/agents": "^2.4.69",
"@librechat/agents": "^2.4.75",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.17.1",
"axios": "^1.8.2",

View File

@@ -1,5 +1,8 @@
import { Tools, type MemoryArtifact } from 'librechat-data-provider';
import { createMemoryTool } from '../memory';
import { Response } from 'express';
import { Providers } from '@librechat/agents';
import { Tools } from 'librechat-data-provider';
import type { MemoryArtifact } from 'librechat-data-provider';
import { createMemoryTool, processMemory } from '../memory';
// Mock the logger
jest.mock('winston', () => ({
@@ -25,6 +28,22 @@ jest.mock('~/utils', () => ({
},
}));
// Mock the Run module
jest.mock('@librechat/agents', () => ({
...jest.requireActual('@librechat/agents'),
Run: {
create: jest.fn(),
},
Providers: {
OPENAI: 'openai',
ANTHROPIC: 'anthropic',
AZURE: 'azure',
},
GraphEvents: {
TOOL_END: 'tool_end',
},
}));
describe('createMemoryTool', () => {
let mockSetMemory: jest.Mock;
@@ -163,3 +182,288 @@ describe('createMemoryTool', () => {
});
});
});
describe('processMemory - GPT-5+ handling', () => {
let mockSetMemory: jest.Mock;
let mockDeleteMemory: jest.Mock;
let mockRes: Partial<Response>;
beforeEach(() => {
jest.clearAllMocks();
mockSetMemory = jest.fn().mockResolvedValue({ ok: true });
mockDeleteMemory = jest.fn().mockResolvedValue({ ok: true });
mockRes = {
headersSent: false,
write: jest.fn(),
};
// Setup the Run.create mock
const { Run } = jest.requireMock('@librechat/agents');
(Run.create as jest.Mock).mockResolvedValue({
processStream: jest.fn().mockResolvedValue('Memory processed'),
});
});
it('should remove temperature for GPT-5 models', async () => {
await processMemory({
res: mockRes as Response,
userId: 'test-user',
setMemory: mockSetMemory,
deleteMemory: mockDeleteMemory,
messages: [],
memory: 'Test memory',
messageId: 'msg-123',
conversationId: 'conv-123',
instructions: 'Test instructions',
llmConfig: {
provider: Providers.OPENAI,
model: 'gpt-5',
temperature: 0.7, // This should be removed
maxTokens: 1000, // This should be moved to modelKwargs
},
});
const { Run } = jest.requireMock('@librechat/agents');
expect(Run.create).toHaveBeenCalledWith(
expect.objectContaining({
graphConfig: expect.objectContaining({
llmConfig: expect.objectContaining({
model: 'gpt-5',
modelKwargs: {
max_completion_tokens: 1000,
},
}),
}),
}),
);
// Verify temperature was removed
const callArgs = (Run.create as jest.Mock).mock.calls[0][0];
expect(callArgs.graphConfig.llmConfig.temperature).toBeUndefined();
expect(callArgs.graphConfig.llmConfig.maxTokens).toBeUndefined();
});
it('should handle GPT-5+ models with existing modelKwargs', async () => {
await processMemory({
res: mockRes as Response,
userId: 'test-user',
setMemory: mockSetMemory,
deleteMemory: mockDeleteMemory,
messages: [],
memory: 'Test memory',
messageId: 'msg-123',
conversationId: 'conv-123',
instructions: 'Test instructions',
llmConfig: {
provider: Providers.OPENAI,
model: 'gpt-6',
temperature: 0.8,
maxTokens: 2000,
modelKwargs: {
customParam: 'value',
},
},
});
const { Run } = jest.requireMock('@librechat/agents');
expect(Run.create).toHaveBeenCalledWith(
expect.objectContaining({
graphConfig: expect.objectContaining({
llmConfig: expect.objectContaining({
model: 'gpt-6',
modelKwargs: {
customParam: 'value',
max_completion_tokens: 2000,
},
}),
}),
}),
);
const callArgs = (Run.create as jest.Mock).mock.calls[0][0];
expect(callArgs.graphConfig.llmConfig.temperature).toBeUndefined();
expect(callArgs.graphConfig.llmConfig.maxTokens).toBeUndefined();
});
it('should not modify non-GPT-5+ models', async () => {
await processMemory({
res: mockRes as Response,
userId: 'test-user',
setMemory: mockSetMemory,
deleteMemory: mockDeleteMemory,
messages: [],
memory: 'Test memory',
messageId: 'msg-123',
conversationId: 'conv-123',
instructions: 'Test instructions',
llmConfig: {
provider: Providers.OPENAI,
model: 'gpt-4',
temperature: 0.7,
maxTokens: 1000,
},
});
const { Run } = jest.requireMock('@librechat/agents');
expect(Run.create).toHaveBeenCalledWith(
expect.objectContaining({
graphConfig: expect.objectContaining({
llmConfig: expect.objectContaining({
model: 'gpt-4',
temperature: 0.7,
maxTokens: 1000,
}),
}),
}),
);
// Verify nothing was moved to modelKwargs for GPT-4
const callArgs = (Run.create as jest.Mock).mock.calls[0][0];
expect(callArgs.graphConfig.llmConfig.modelKwargs).toBeUndefined();
});
it('should handle various GPT-5+ model formats', async () => {
const testCases = [
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-7-preview', shouldTransform: true },
{ model: 'gpt-9', shouldTransform: true },
{ model: 'gpt-4o', shouldTransform: false },
{ model: 'gpt-3.5-turbo', shouldTransform: false },
];
for (const { model, shouldTransform } of testCases) {
jest.clearAllMocks();
const { Run } = jest.requireMock('@librechat/agents');
(Run.create as jest.Mock).mockResolvedValue({
processStream: jest.fn().mockResolvedValue('Memory processed'),
});
await processMemory({
res: mockRes as Response,
userId: 'test-user',
setMemory: mockSetMemory,
deleteMemory: mockDeleteMemory,
messages: [],
memory: 'Test memory',
messageId: 'msg-123',
conversationId: 'conv-123',
instructions: 'Test instructions',
llmConfig: {
provider: Providers.OPENAI,
model,
temperature: 0.5,
maxTokens: 1500,
},
});
const callArgs = (Run.create as jest.Mock).mock.calls[0][0];
const llmConfig = callArgs.graphConfig.llmConfig;
if (shouldTransform) {
expect(llmConfig.temperature).toBeUndefined();
expect(llmConfig.maxTokens).toBeUndefined();
expect(llmConfig.modelKwargs?.max_completion_tokens).toBe(1500);
} else {
expect(llmConfig.temperature).toBe(0.5);
expect(llmConfig.maxTokens).toBe(1500);
expect(llmConfig.modelKwargs).toBeUndefined();
}
}
});
it('should use default model (gpt-4.1-mini) without temperature removal when no llmConfig provided', async () => {
await processMemory({
res: mockRes as Response,
userId: 'test-user',
setMemory: mockSetMemory,
deleteMemory: mockDeleteMemory,
messages: [],
memory: 'Test memory',
messageId: 'msg-123',
conversationId: 'conv-123',
instructions: 'Test instructions',
// No llmConfig provided
});
const { Run } = jest.requireMock('@librechat/agents');
expect(Run.create).toHaveBeenCalledWith(
expect.objectContaining({
graphConfig: expect.objectContaining({
llmConfig: expect.objectContaining({
model: 'gpt-4.1-mini',
temperature: 0.4, // Default temperature should remain
}),
}),
}),
);
});
it('should use max_output_tokens when useResponsesApi is true', async () => {
await processMemory({
res: mockRes as Response,
userId: 'test-user',
setMemory: mockSetMemory,
deleteMemory: mockDeleteMemory,
messages: [],
memory: 'Test memory',
messageId: 'msg-123',
conversationId: 'conv-123',
instructions: 'Test instructions',
llmConfig: {
provider: Providers.OPENAI,
model: 'gpt-5',
maxTokens: 1000,
useResponsesApi: true,
},
});
const { Run } = jest.requireMock('@librechat/agents');
expect(Run.create).toHaveBeenCalledWith(
expect.objectContaining({
graphConfig: expect.objectContaining({
llmConfig: expect.objectContaining({
model: 'gpt-5',
modelKwargs: {
max_output_tokens: 1000,
},
}),
}),
}),
);
});
it('should use max_completion_tokens when useResponsesApi is false or undefined', async () => {
await processMemory({
res: mockRes as Response,
userId: 'test-user',
setMemory: mockSetMemory,
deleteMemory: mockDeleteMemory,
messages: [],
memory: 'Test memory',
messageId: 'msg-123',
conversationId: 'conv-123',
instructions: 'Test instructions',
llmConfig: {
provider: Providers.OPENAI,
model: 'gpt-5',
maxTokens: 1000,
useResponsesApi: false,
},
});
const { Run } = jest.requireMock('@librechat/agents');
expect(Run.create).toHaveBeenCalledWith(
expect.objectContaining({
graphConfig: expect.objectContaining({
llmConfig: expect.objectContaining({
model: 'gpt-5',
modelKwargs: {
max_completion_tokens: 1000,
},
}),
}),
}),
);
});
});

View File

@@ -5,8 +5,10 @@ import { Tools } from 'librechat-data-provider';
import { logger } from '@librechat/data-schemas';
import { Run, Providers, GraphEvents } from '@librechat/agents';
import type {
OpenAIClientOptions,
StreamEventData,
ToolEndCallback,
ClientOptions,
EventHandler,
ToolEndData,
LLMConfig,
@@ -332,7 +334,7 @@ ${memory ?? 'No existing memories'}`;
disableStreaming: true,
};
const finalLLMConfig = {
const finalLLMConfig: ClientOptions = {
...defaultLLMConfig,
...llmConfig,
/**
@@ -342,6 +344,24 @@ ${memory ?? 'No existing memories'}`;
disableStreaming: true,
};
// Handle GPT-5+ models
if ('model' in finalLLMConfig && /\bgpt-[5-9]\b/i.test(finalLLMConfig.model ?? '')) {
// Remove temperature for GPT-5+ models
delete finalLLMConfig.temperature;
// Move maxTokens to modelKwargs for GPT-5+ models
if ('maxTokens' in finalLLMConfig && finalLLMConfig.maxTokens != null) {
const modelKwargs = (finalLLMConfig as OpenAIClientOptions).modelKwargs ?? {};
const paramName =
(finalLLMConfig as OpenAIClientOptions).useResponsesApi === true
? 'max_output_tokens'
: 'max_completion_tokens';
modelKwargs[paramName] = finalLLMConfig.maxTokens;
delete finalLLMConfig.maxTokens;
(finalLLMConfig as OpenAIClientOptions).modelKwargs = modelKwargs;
}
}
const artifactPromises: Promise<TAttachment | null>[] = [];
const memoryCallback = createMemoryCallback({ res, artifactPromises });
const customHandlers = {

View File

@@ -1,4 +1,7 @@
import { z } from 'zod';
import { ViolationTypes, ErrorTypes } from 'librechat-data-provider';
import type { Agent, TModelsConfig } from 'librechat-data-provider';
import type { Request, Response } from 'express';
/** Avatar schema shared between create and update */
export const agentAvatarSchema = z.object({
@@ -59,3 +62,90 @@ export const agentUpdateSchema = agentBaseSchema.extend({
removeProjectIds: z.array(z.string()).optional(),
isCollaborative: z.boolean().optional(),
});
interface ValidateAgentModelParams {
req: Request;
res: Response;
agent: Agent;
modelsConfig: TModelsConfig;
logViolation: (
req: Request,
res: Response,
type: string,
errorMessage: Record<string, unknown>,
score?: number | string,
) => Promise<void>;
}
interface ValidateAgentModelResult {
isValid: boolean;
error?: {
message: string;
};
}
/**
* Validates an agent's model against the available models configuration.
* This is a non-middleware version of validateModel that can be used
* in service initialization flows.
*
* @param params - Validation parameters
* @returns Object indicating whether the model is valid and any error details
*/
export async function validateAgentModel(
params: ValidateAgentModelParams,
): Promise<ValidateAgentModelResult> {
const { req, res, agent, modelsConfig, logViolation } = params;
const { model, provider: endpoint } = agent;
if (!model) {
return {
isValid: false,
error: {
message: `{ "type": "${ErrorTypes.MISSING_MODEL}", "info": "${endpoint}" }`,
},
};
}
if (!modelsConfig) {
return {
isValid: false,
error: {
message: `{ "type": "${ErrorTypes.MODELS_NOT_LOADED}" }`,
},
};
}
const availableModels = modelsConfig[endpoint];
if (!availableModels) {
return {
isValid: false,
error: {
message: `{ "type": "${ErrorTypes.ENDPOINT_MODELS_NOT_LOADED}", "info": "${endpoint}" }`,
},
};
}
const validModel = !!availableModels.find((availableModel) => availableModel === model);
if (validModel) {
return { isValid: true };
}
const { ILLEGAL_MODEL_REQ_SCORE: score = 1 } = process.env ?? {};
const type = ViolationTypes.ILLEGAL_MODEL_REQUEST;
const errorMessage = {
type,
model,
endpoint,
};
await logViolation(req, res, type, errorMessage, score);
return {
isValid: false,
error: {
message: `{ "type": "${ViolationTypes.ILLEGAL_MODEL_REQUEST}", "info": "${endpoint}|${model}" }`,
},
};
}

View File

@@ -0,0 +1,424 @@
import { ReasoningEffort, ReasoningSummary, Verbosity } from 'librechat-data-provider';
import type { RequestInit } from 'undici';
import { getOpenAIConfig } from './llm';
describe('getOpenAIConfig', () => {
const mockApiKey = 'test-api-key';
it('should create basic config with default values', () => {
const result = getOpenAIConfig(mockApiKey);
expect(result.llmConfig).toMatchObject({
streaming: true,
model: '',
apiKey: mockApiKey,
});
expect(result.configOptions).toEqual({});
expect(result.tools).toEqual([]);
});
it('should apply model options', () => {
const modelOptions = {
model: 'gpt-5',
temperature: 0.7,
max_tokens: 1000,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-5',
temperature: 0.7,
modelKwargs: {
max_completion_tokens: 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', () => {
const addParams = {
temperature: 0.5, // known param
topP: 0.9, // known param
customParam1: 'value1', // unknown param
customParam2: { nested: true }, // unknown param
maxTokens: 500, // known param
};
const result = getOpenAIConfig(mockApiKey, { addParams });
expect(result.llmConfig.temperature).toBe(0.5);
expect(result.llmConfig.topP).toBe(0.9);
expect(result.llmConfig.maxTokens).toBe(500);
expect(result.llmConfig.modelKwargs).toEqual({
customParam1: 'value1',
customParam2: { nested: true },
});
});
it('should not add modelKwargs if all params are known', () => {
const addParams = {
temperature: 0.5,
topP: 0.9,
maxTokens: 500,
};
const result = getOpenAIConfig(mockApiKey, { addParams });
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
it('should handle empty addParams', () => {
const result = getOpenAIConfig(mockApiKey, { addParams: {} });
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
it('should handle reasoning params for useResponsesApi', () => {
const modelOptions = {
reasoning_effort: ReasoningEffort.high,
reasoning_summary: ReasoningSummary.detailed,
};
const result = getOpenAIConfig(mockApiKey, {
modelOptions: { ...modelOptions, useResponsesApi: true },
});
expect(result.llmConfig.reasoning).toEqual({
effort: ReasoningEffort.high,
summary: ReasoningSummary.detailed,
});
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
expect((result.llmConfig as Record<string, unknown>).reasoning_summary).toBeUndefined();
});
it('should handle reasoning params without useResponsesApi', () => {
const modelOptions = {
reasoning_effort: ReasoningEffort.high,
reasoning_summary: ReasoningSummary.detailed,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBe(
ReasoningEffort.high,
);
expect(result.llmConfig.reasoning).toBeUndefined();
});
it('should handle OpenRouter configuration', () => {
const reverseProxyUrl = 'https://openrouter.ai/api/v1';
const result = getOpenAIConfig(mockApiKey, { reverseProxyUrl });
expect(result.configOptions?.baseURL).toBe(reverseProxyUrl);
expect(result.configOptions?.defaultHeaders).toMatchObject({
'HTTP-Referer': 'https://librechat.ai',
'X-Title': 'LibreChat',
});
expect(result.llmConfig.include_reasoning).toBe(true);
expect(result.provider).toBe('openrouter');
});
it('should handle Azure configuration', () => {
const azure = {
azureOpenAIApiInstanceName: 'test-instance',
azureOpenAIApiDeploymentName: 'test-deployment',
azureOpenAIApiVersion: '2023-05-15',
azureOpenAIApiKey: 'azure-key',
};
const result = getOpenAIConfig(mockApiKey, { azure });
expect(result.llmConfig).toMatchObject({
...azure,
model: 'test-deployment',
});
});
it('should handle web search model option', () => {
const modelOptions = {
model: 'gpt-5',
web_search: true,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig.useResponsesApi).toBe(true);
expect(result.tools).toEqual([{ type: 'web_search_preview' }]);
});
it('should drop params for search models', () => {
const modelOptions = {
model: 'gpt-4o-search',
temperature: 0.7,
frequency_penalty: 0.5,
max_tokens: 1000,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig.temperature).toBeUndefined();
expect((result.llmConfig as Record<string, unknown>).frequency_penalty).toBeUndefined();
expect(result.llmConfig.maxTokens).toBe(1000); // max_tokens is allowed
});
it('should handle custom dropParams', () => {
const modelOptions = {
temperature: 0.7,
topP: 0.9,
customParam: 'value',
};
const result = getOpenAIConfig(mockApiKey, {
modelOptions,
dropParams: ['temperature', 'customParam'],
});
expect(result.llmConfig.temperature).toBeUndefined();
expect(result.llmConfig.topP).toBe(0.9);
expect((result.llmConfig as Record<string, unknown>).customParam).toBeUndefined();
});
it('should handle proxy configuration', () => {
const proxy = 'http://proxy.example.com:8080';
const result = getOpenAIConfig(mockApiKey, { proxy });
expect(result.configOptions?.fetchOptions).toBeDefined();
expect((result.configOptions?.fetchOptions as RequestInit).dispatcher).toBeDefined();
});
it('should handle headers and defaultQuery', () => {
const headers = { 'X-Custom-Header': 'value' };
const defaultQuery = { customParam: 'value' };
const result = getOpenAIConfig(mockApiKey, {
reverseProxyUrl: 'https://api.example.com',
headers,
defaultQuery,
});
expect(result.configOptions?.baseURL).toBe('https://api.example.com');
expect(result.configOptions?.defaultHeaders).toEqual(headers);
expect(result.configOptions?.defaultQuery).toEqual(defaultQuery);
});
it('should handle verbosity parameter in modelKwargs', () => {
const modelOptions = {
model: 'gpt-5',
temperature: 0.7,
verbosity: Verbosity.high,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-5',
temperature: 0.7,
});
expect(result.llmConfig.modelKwargs).toEqual({
verbosity: Verbosity.high,
});
});
it('should allow addParams to override verbosity in modelKwargs', () => {
const modelOptions = {
model: 'gpt-5',
verbosity: Verbosity.low,
};
const addParams = {
temperature: 0.8,
verbosity: Verbosity.high, // This should override the one from modelOptions
customParam: 'value',
};
const result = getOpenAIConfig(mockApiKey, { modelOptions, addParams });
expect(result.llmConfig.temperature).toBe(0.8);
expect(result.llmConfig.modelKwargs).toEqual({
verbosity: Verbosity.high, // Should be overridden by addParams
customParam: 'value',
});
});
it('should not create modelKwargs if verbosity is empty or null', () => {
const testCases = [
{ verbosity: null },
{ verbosity: Verbosity.none },
{ verbosity: undefined },
];
testCases.forEach((modelOptions) => {
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
});
it('should nest verbosity under text when useResponsesApi is enabled', () => {
const modelOptions = {
model: 'gpt-5',
temperature: 0.7,
verbosity: Verbosity.low,
useResponsesApi: true,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-5',
temperature: 0.7,
useResponsesApi: true,
});
expect(result.llmConfig.modelKwargs).toEqual({
text: {
verbosity: Verbosity.low,
},
});
});
it('should handle verbosity correctly when addParams overrides with useResponsesApi', () => {
const modelOptions = {
model: 'gpt-5',
verbosity: Verbosity.low,
useResponsesApi: true,
};
const addParams = {
verbosity: Verbosity.high,
customParam: 'value',
};
const result = getOpenAIConfig(mockApiKey, { modelOptions, addParams });
expect(result.llmConfig.modelKwargs).toEqual({
text: {
verbosity: Verbosity.high, // Should be overridden by addParams
},
customParam: 'value',
});
});
it('should move maxTokens to modelKwargs.max_completion_tokens for GPT-5+ models', () => {
const modelOptions = {
model: 'gpt-5',
temperature: 0.7,
max_tokens: 2048,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-5',
temperature: 0.7,
});
expect(result.llmConfig.maxTokens).toBeUndefined();
expect(result.llmConfig.modelKwargs).toEqual({
max_completion_tokens: 2048,
});
});
it('should handle GPT-5+ models with existing modelKwargs', () => {
const modelOptions = {
model: 'gpt-6',
max_tokens: 1000,
verbosity: Verbosity.low,
};
const addParams = {
customParam: 'value',
};
const result = getOpenAIConfig(mockApiKey, { modelOptions, addParams });
expect(result.llmConfig.maxTokens).toBeUndefined();
expect(result.llmConfig.modelKwargs).toEqual({
verbosity: Verbosity.low,
customParam: 'value',
max_completion_tokens: 1000,
});
});
it('should not move maxTokens for non-GPT-5+ models', () => {
const modelOptions = {
model: 'gpt-4',
temperature: 0.7,
max_tokens: 2048,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig).toMatchObject({
model: 'gpt-4',
temperature: 0.7,
maxTokens: 2048,
});
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
it('should handle GPT-5+ models with verbosity and useResponsesApi', () => {
const modelOptions = {
model: 'gpt-5',
max_tokens: 1500,
verbosity: Verbosity.medium,
useResponsesApi: true,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
expect(result.llmConfig.maxTokens).toBeUndefined();
expect(result.llmConfig.modelKwargs).toEqual({
text: {
verbosity: Verbosity.medium,
},
max_output_tokens: 1500,
});
});
it('should handle complex addParams with mixed known and unknown params', () => {
const addParams = {
// Known params
model: 'gpt-4-turbo',
temperature: 0.8,
topP: 0.95,
frequencyPenalty: 0.2,
presencePenalty: 0.1,
maxTokens: 2048,
stop: ['\\n\\n', 'END'],
stream: false,
// Unknown params
custom_instruction: 'Be concise',
response_style: 'formal',
domain_specific: {
medical: true,
terminology: 'advanced',
},
};
const result = getOpenAIConfig(mockApiKey, { addParams });
// Check known params are in llmConfig
expect(result.llmConfig).toMatchObject({
model: 'gpt-4-turbo',
temperature: 0.8,
topP: 0.95,
frequencyPenalty: 0.2,
presencePenalty: 0.1,
maxTokens: 2048,
stop: ['\\n\\n', 'END'],
stream: false,
});
// Check unknown params are in modelKwargs
expect(result.llmConfig.modelKwargs).toEqual({
custom_instruction: 'Be concise',
response_style: 'formal',
domain_specific: {
medical: true,
terminology: 'advanced',
},
});
});
});

View File

@@ -1,4 +1,5 @@
import { ProxyAgent } from 'undici';
import { Providers } from '@librechat/agents';
import { KnownEndpoints, removeNullishValues } from 'librechat-data-provider';
import type { BindToolsInput } from '@langchain/core/language_models/chat_models';
import type { AzureOpenAIInput } from '@langchain/openai';
@@ -7,6 +8,62 @@ import type * as t from '~/types';
import { sanitizeModelName, constructAzureURL } from '~/utils/azure';
import { isEnabled } from '~/utils/common';
export const knownOpenAIParams = new Set([
// Constructor/Instance Parameters
'model',
'modelName',
'temperature',
'topP',
'frequencyPenalty',
'presencePenalty',
'n',
'logitBias',
'stop',
'stopSequences',
'user',
'timeout',
'stream',
'maxTokens',
'maxCompletionTokens',
'logprobs',
'topLogprobs',
'apiKey',
'organization',
'audio',
'modalities',
'reasoning',
'zdrEnabled',
'service_tier',
'supportsStrictToolCalling',
'useResponsesApi',
'configuration',
// Call-time Options
'tools',
'tool_choice',
'functions',
'function_call',
'response_format',
'seed',
'stream_options',
'parallel_tool_calls',
'strict',
'prediction',
'promptIndex',
// Responses API specific
'text',
'truncation',
'include',
'previous_response_id',
// LangChain specific
'__includeRawResponse',
'maxConcurrency',
'maxRetries',
'verbose',
'streaming',
'streamUsage',
'disableStreaming',
]);
function hasReasoningParams({
reasoning_effort,
reasoning_summary,
@@ -43,7 +100,7 @@ export function getOpenAIConfig(
addParams,
dropParams,
} = options;
const { reasoning_effort, reasoning_summary, ...modelOptions } = _modelOptions;
const { reasoning_effort, reasoning_summary, verbosity, ...modelOptions } = _modelOptions;
const llmConfig: Partial<t.ClientOptions> &
Partial<t.OpenAIParameters> &
Partial<AzureOpenAIInput> = Object.assign(
@@ -54,8 +111,23 @@ export function getOpenAIConfig(
modelOptions,
);
const modelKwargs: Record<string, unknown> = {};
let hasModelKwargs = false;
if (verbosity != null && verbosity !== '') {
modelKwargs.verbosity = verbosity;
hasModelKwargs = true;
}
if (addParams && typeof addParams === 'object') {
Object.assign(llmConfig, addParams);
for (const [key, value] of Object.entries(addParams)) {
if (knownOpenAIParams.has(key)) {
(llmConfig as Record<string, unknown>)[key] = value;
} else {
hasModelKwargs = true;
modelKwargs[key] = value;
}
}
}
let useOpenRouter = false;
@@ -222,9 +294,30 @@ export function getOpenAIConfig(
});
}
return {
if (modelKwargs.verbosity && llmConfig.useResponsesApi === true) {
modelKwargs.text = { verbosity: modelKwargs.verbosity };
delete modelKwargs.verbosity;
}
if (llmConfig.model && /\bgpt-[5-9]\b/i.test(llmConfig.model) && llmConfig.maxTokens != null) {
const paramName =
llmConfig.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
modelKwargs[paramName] = llmConfig.maxTokens;
delete llmConfig.maxTokens;
hasModelKwargs = true;
}
if (hasModelKwargs) {
llmConfig.modelKwargs = modelKwargs;
}
const result: t.LLMConfigResult = {
llmConfig,
configOptions,
tools,
};
if (useOpenRouter) {
result.provider = Providers.OPENROUTER;
}
return result;
}

View File

@@ -15,12 +15,16 @@ export * from './crypto';
export * from './flow/manager';
/* Middleware */
export * from './middleware';
/* Memory */
export * from './memory';
/* Agents */
export * from './agents';
/* Endpoints */
export * from './endpoints';
/* Files */
export * from './files';
/* Tools */
export * from './tools';
/* web search */
export * from './web';
/* types */

View File

@@ -0,0 +1,28 @@
import { memorySchema } from 'librechat-data-provider';
import type { TCustomConfig, TMemoryConfig } from 'librechat-data-provider';
const hasValidAgent = (agent: TMemoryConfig['agent']) =>
!!agent &&
(('id' in agent && !!agent.id) ||
('provider' in agent && 'model' in agent && !!agent.provider && !!agent.model));
const isDisabled = (config?: TMemoryConfig | TCustomConfig['memory']) =>
!config || config.disabled === true;
export function loadMemoryConfig(config: TCustomConfig['memory']): TMemoryConfig | undefined {
if (!config) return undefined;
if (isDisabled(config)) return config as TMemoryConfig;
if (!hasValidAgent(config.agent)) {
return { ...config, disabled: true } as TMemoryConfig;
}
const charLimit = memorySchema.shape.charLimit.safeParse(config.charLimit).data ?? 10000;
return { ...config, charLimit };
}
export function isMemoryEnabled(config: TMemoryConfig | undefined): boolean {
if (isDisabled(config)) return false;
return hasValidAgent(config!.agent);
}

View File

@@ -0,0 +1 @@
export * from './config';

View File

@@ -1,36 +1,43 @@
const errorController = require('./ErrorController');
const { logger } = require('~/config');
import { logger } from '@librechat/data-schemas';
import { ErrorController } from './error';
import type { Request, Response } from 'express';
import type { ValidationError, MongoServerError, CustomError } from '~/types';
// Mock the logger
jest.mock('~/config', () => ({
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
error: jest.fn(),
warn: jest.fn(),
},
}));
describe('ErrorController', () => {
let mockReq, mockRes, mockNext;
let mockReq: Request;
let mockRes: Response;
beforeEach(() => {
mockReq = {};
mockReq = {
originalUrl: '',
} as Request;
mockRes = {
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
mockNext = jest.fn();
logger.error.mockClear();
} as unknown as Response;
(logger.error as jest.Mock).mockClear();
});
describe('ValidationError handling', () => {
it('should handle ValidationError with single error', () => {
const validationError = {
name: 'ValidationError',
message: 'Validation error',
errors: {
email: { message: 'Email is required', path: 'email' },
},
};
} as ValidationError;
errorController(validationError, mockReq, mockRes, mockNext);
ErrorController(validationError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith({
@@ -43,13 +50,14 @@ describe('ErrorController', () => {
it('should handle ValidationError with multiple errors', () => {
const validationError = {
name: 'ValidationError',
message: 'Validation error',
errors: {
email: { message: 'Email is required', path: 'email' },
password: { message: 'Password is required', path: 'password' },
},
};
} as ValidationError;
errorController(validationError, mockReq, mockRes, mockNext);
ErrorController(validationError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith({
@@ -63,9 +71,9 @@ describe('ErrorController', () => {
const validationError = {
name: 'ValidationError',
errors: {},
};
} as ValidationError;
errorController(validationError, mockReq, mockRes, mockNext);
ErrorController(validationError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith({
@@ -78,43 +86,59 @@ describe('ErrorController', () => {
describe('Duplicate key error handling', () => {
it('should handle duplicate key error (code 11000)', () => {
const duplicateKeyError = {
name: 'MongoServerError',
message: 'Duplicate key error',
code: 11000,
keyValue: { email: 'test@example.com' },
};
errmsg:
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
} as MongoServerError;
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
ErrorController(duplicateKeyError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(409);
expect(mockRes.send).toHaveBeenCalledWith({
messages: 'An document with that ["email"] already exists.',
fields: '["email"]',
});
expect(logger.error).toHaveBeenCalledWith('Duplicate key error:', duplicateKeyError.keyValue);
expect(logger.warn).toHaveBeenCalledWith(
'Duplicate key error: E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
);
});
it('should handle duplicate key error with multiple fields', () => {
const duplicateKeyError = {
name: 'MongoServerError',
message: 'Duplicate key error',
code: 11000,
keyValue: { email: 'test@example.com', username: 'testuser' },
};
errmsg:
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
} as MongoServerError;
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
ErrorController(duplicateKeyError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(409);
expect(mockRes.send).toHaveBeenCalledWith({
messages: 'An document with that ["email","username"] already exists.',
fields: '["email","username"]',
});
expect(logger.error).toHaveBeenCalledWith('Duplicate key error:', duplicateKeyError.keyValue);
expect(logger.warn).toHaveBeenCalledWith(
'Duplicate key error: E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
);
});
it('should handle error with code 11000 as string', () => {
const duplicateKeyError = {
code: '11000',
name: 'MongoServerError',
message: 'Duplicate key error',
code: 11000,
keyValue: { email: 'test@example.com' },
};
errmsg:
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
} as MongoServerError;
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
ErrorController(duplicateKeyError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(409);
expect(mockRes.send).toHaveBeenCalledWith({
@@ -129,9 +153,9 @@ describe('ErrorController', () => {
const syntaxError = {
statusCode: 400,
body: 'Invalid JSON syntax',
};
} as CustomError;
errorController(syntaxError, mockReq, mockRes, mockNext);
ErrorController(syntaxError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith('Invalid JSON syntax');
@@ -141,9 +165,9 @@ describe('ErrorController', () => {
const customError = {
statusCode: 422,
body: { error: 'Unprocessable entity' },
};
} as CustomError;
errorController(customError, mockReq, mockRes, mockNext);
ErrorController(customError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(422);
expect(mockRes.send).toHaveBeenCalledWith({ error: 'Unprocessable entity' });
@@ -152,9 +176,9 @@ describe('ErrorController', () => {
it('should handle error with statusCode but no body', () => {
const partialError = {
statusCode: 400,
};
} as CustomError;
errorController(partialError, mockReq, mockRes, mockNext);
ErrorController(partialError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
@@ -163,9 +187,9 @@ describe('ErrorController', () => {
it('should handle error with body but no statusCode', () => {
const partialError = {
body: 'Some error message',
};
} as CustomError;
errorController(partialError, mockReq, mockRes, mockNext);
ErrorController(partialError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
@@ -176,7 +200,7 @@ describe('ErrorController', () => {
it('should handle unknown errors', () => {
const unknownError = new Error('Some unknown error');
errorController(unknownError, mockReq, mockRes, mockNext);
ErrorController(unknownError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
@@ -187,32 +211,31 @@ describe('ErrorController', () => {
const mongoError = {
code: 11100,
message: 'Some MongoDB error',
};
} as MongoServerError;
errorController(mongoError, mockReq, mockRes, mockNext);
ErrorController(mongoError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', mongoError);
});
it('should handle null/undefined errors', () => {
errorController(null, mockReq, mockRes, mockNext);
it('should handle generic errors', () => {
const genericError = new Error('Test error');
ErrorController(genericError, mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');
expect(logger.error).toHaveBeenCalledWith(
'ErrorController => processing error',
expect.any(Error),
);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', genericError);
});
});
describe('Catch block handling', () => {
beforeEach(() => {
// Restore logger mock to normal behavior for these tests
logger.error.mockRestore();
logger.error = jest.fn();
(logger.error as jest.Mock).mockRestore();
(logger.error as jest.Mock) = jest.fn();
});
it('should handle errors when logger.error throws', () => {
@@ -220,10 +243,10 @@ describe('ErrorController', () => {
const freshMockRes = {
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
} as unknown as Response;
// Mock logger to throw on the first call, succeed on the second
logger.error
(logger.error as jest.Mock)
.mockImplementationOnce(() => {
throw new Error('Logger error');
})
@@ -231,7 +254,7 @@ describe('ErrorController', () => {
const testError = new Error('Test error');
errorController(testError, mockReq, freshMockRes, mockNext);
ErrorController(testError, mockReq, freshMockRes);
expect(freshMockRes.status).toHaveBeenCalledWith(500);
expect(freshMockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');

View File

@@ -0,0 +1,83 @@
import { logger } from '@librechat/data-schemas';
import { ErrorTypes } from 'librechat-data-provider';
import type { NextFunction, Request, Response } from 'express';
import type { MongoServerError, ValidationError, CustomError } from '~/types';
const handleDuplicateKeyError = (err: MongoServerError, res: Response) => {
logger.warn('Duplicate key error: ' + (err.errmsg || err.message));
const field = err.keyValue ? `${JSON.stringify(Object.keys(err.keyValue))}` : 'unknown';
const code = 409;
res
.status(code)
.send({ messages: `An document with that ${field} already exists.`, fields: field });
};
const handleValidationError = (err: ValidationError, res: Response) => {
logger.error('Validation error:', err.errors);
const errorMessages = Object.values(err.errors).map((el) => el.message);
const fields = `${JSON.stringify(Object.values(err.errors).map((el) => el.path))}`;
const code = 400;
const messages =
errorMessages.length > 1
? `${JSON.stringify(errorMessages.join(' '))}`
: `${JSON.stringify(errorMessages)}`;
res.status(code).send({ messages, fields });
};
/** Type guard for ValidationError */
function isValidationError(err: unknown): err is ValidationError {
return err !== null && typeof err === 'object' && 'name' in err && err.name === 'ValidationError';
}
/** Type guard for MongoServerError (duplicate key) */
function isMongoServerError(err: unknown): err is MongoServerError {
return err !== null && typeof err === 'object' && 'code' in err && err.code === 11000;
}
/** Type guard for CustomError with statusCode and body */
function isCustomError(err: unknown): err is CustomError {
return err !== null && typeof err === 'object' && 'statusCode' in err && 'body' in err;
}
export const ErrorController = (
err: Error | CustomError,
req: Request,
res: Response,
next: NextFunction,
): Response | void => {
try {
if (!err) {
return next();
}
const error = err as CustomError;
if (
(error.message === ErrorTypes.AUTH_FAILED || error.code === ErrorTypes.AUTH_FAILED) &&
req.originalUrl &&
req.originalUrl.includes('/oauth/') &&
req.originalUrl.includes('/callback')
) {
const domain = process.env.DOMAIN_CLIENT || 'http://localhost:3080';
return res.redirect(`${domain}/login?redirect=false&error=${ErrorTypes.AUTH_FAILED}`);
}
if (isValidationError(error)) {
return handleValidationError(error, res);
}
if (isMongoServerError(error)) {
return handleDuplicateKeyError(error, res);
}
if (isCustomError(error) && error.statusCode && error.body) {
return res.status(error.statusCode).send(error.body);
}
logger.error('ErrorController => error', err);
return res.status(500).send('An unknown error occurred.');
} catch (processingError) {
logger.error('ErrorController => processing error', processingError);
return res.status(500).send('Processing error in ErrorController.');
}
};

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