Compare commits

..

37 Commits

Author SHA1 Message Date
Dustin Healy
e9d0442531 feat: add body to other endpoints 2025-08-10 16:00:29 -07:00
Dustin Healy
274987712c feat: add body for mcp tool calls 2025-08-10 16:00:29 -07:00
Gopal Sharma
888e3a31cf Merge branch 'dev' into feat/custom-endpoint-conversation-id 2025-08-10 23:47:15 +05:30
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
Dustin Healy
7ea23c5a7d fix: type error in unit test 2025-08-09 16:04:54 -07:00
Dustin Healy
f4833b6b25 style: minor styling cleanup 2025-08-09 16:02:51 -07:00
Gopal Sharma
d37db43e29 refactor resolveHeaders 2025-08-10 03:55:35 +05:30
Gopal Sharma
eec10bf745 feat: add support for request body placeholders in custom endpoint headers
- Add {{LIBRECHAT_BODY_*}} placeholders for conversationId, parentMessageId, messageId
- Update tests to reflect new body placeholder functionality
2025-08-10 03:55:35 +05:30
s10gopal
3508839d6d fix: filter out unresolved placeholders from headers (thanks @MrunmayS) 2025-08-10 03:55:35 +05:30
Gopal Sharma
a8babbcebf feat: Add conversation ID support to custom endpoint headers
- Add LIBRECHAT_CONVERSATION_ID to customUserVars when provided
- Pass conversation ID to header resolution for dynamic headers
- Add comprehensive test coverage

Enables custom endpoints to access conversation context using {{LIBRECHAT_CONVERSATION_ID}} placeholder.
2025-08-10 03:55:35 +05:30
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
87 changed files with 3644 additions and 583 deletions

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

@@ -653,8 +653,10 @@ class OpenAIClient extends BaseClient {
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
configOptions.baseOptions = {
headers: resolveHeaders({
...headers,
...configOptions?.baseOptions?.headers,
headers: {
...headers,
...configOptions?.baseOptions?.headers,
},
}),
};
}
@@ -749,7 +751,7 @@ class OpenAIClient extends BaseClient {
groupMap,
});
this.options.headers = resolveHeaders(headers);
this.options.headers = resolveHeaders({ headers });
this.options.reverseProxyUrl = baseURL ?? null;
this.langchainProxy = extractBaseURL(this.options.reverseProxyUrl);
this.apiKey = azureOptions.azureOpenAIApiKey;
@@ -1181,7 +1183,7 @@ ${convo}
modelGroupMap,
groupMap,
});
opts.defaultHeaders = resolveHeaders(headers);
opts.defaultHeaders = resolveHeaders({ headers });
this.langchainProxy = extractBaseURL(baseURL);
this.apiKey = azureOptions.azureOpenAIApiKey;
@@ -1222,7 +1224,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

@@ -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,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

@@ -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

@@ -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

@@ -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

@@ -109,14 +109,15 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie
apiKey = azureOptions.azureOpenAIApiKey;
opts.defaultQuery = { 'api-version': azureOptions.azureOpenAIApiVersion };
opts.defaultHeaders = resolveHeaders(
{
opts.defaultHeaders = resolveHeaders({
headers: {
...headers,
'api-key': apiKey,
'OpenAI-Beta': `assistants=${version}`,
},
req.user,
);
user: req.user,
body: req.body,
});
opts.model = azureOptions.azureOpenAIApiDeploymentName;
if (initAppClient) {

View File

@@ -28,7 +28,11 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
let resolvedHeaders = resolveHeaders(endpointConfig.headers, req.user);
let resolvedHeaders = resolveHeaders({
headers: endpointConfig.headers,
user: req.user,
body: req.body,
});
if (CUSTOM_API_KEY.match(envVarRegex)) {
throw new Error(`Missing API Key for ${endpoint}.`);

View File

@@ -64,13 +64,14 @@ describe('custom/initializeClient', () => {
jest.clearAllMocks();
});
it('calls resolveHeaders with headers and user', async () => {
it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => {
const { resolveHeaders } = require('@librechat/api');
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
expect(resolveHeaders).toHaveBeenCalledWith(
{ 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
{ id: 'user-123', email: 'test@example.com' },
);
expect(resolveHeaders).toHaveBeenCalledWith({
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
user: { id: 'user-123', email: 'test@example.com' },
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders
});
});
it('throws if endpoint config is missing', async () => {

View File

@@ -81,10 +81,11 @@ const initializeClient = async ({
serverless = _serverless;
clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl;
clientOptions.headers = resolveHeaders(
{ ...headers, ...(clientOptions.headers ?? {}) },
req.user,
);
clientOptions.headers = resolveHeaders({
headers: { ...headers, ...(clientOptions.headers ?? {}) },
user: req.user,
body: req.body,
});
clientOptions.titleConvo = azureConfig.titleConvo;
clientOptions.titleModel = azureConfig.titleModel;

View File

@@ -189,6 +189,7 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
},
oauthStart,
oauthEnd,
body: req.body,
});
if (isAssistantsEndpoint(provider) && Array.isArray(result)) {

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -229,7 +229,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 +238,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 +287,7 @@
"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_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.",
@@ -448,7 +450,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 +556,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 +883,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

@@ -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",

View File

@@ -540,7 +540,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": "空のファイルはアップロードできません",
@@ -432,8 +436,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 +447,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 +528,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 +555,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 +598,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 +656,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 +849,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 +882,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 +918,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 +951,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 +991,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 +1044,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

@@ -549,7 +549,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

@@ -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

@@ -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

@@ -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"

View File

@@ -259,6 +259,8 @@ endpoints:
# recommended environment variables:
apiKey: '${OPENROUTER_KEY}'
baseURL: 'https://openrouter.ai/api/v1'
headers:
x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}'
models:
default: ['meta-llama/llama-3-70b-instruct']
fetch: true

83
package-lock.json generated
View File

@@ -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",
@@ -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/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

@@ -87,10 +87,10 @@ export const initializeOpenAI = async ({
});
clientOptions.reverseProxyUrl = configBaseURL ?? clientOptions.reverseProxyUrl;
clientOptions.headers = resolveHeaders(
{ ...headers, ...(clientOptions.headers ?? {}) },
req.user,
);
clientOptions.headers = resolveHeaders({
headers: { ...headers, ...(clientOptions.headers ?? {}) },
user: req.user,
});
const groupName = modelGroupMap[modelName || '']?.group;
if (groupName && groupMap[groupName]) {

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

@@ -21,6 +21,8 @@ export * from './agents';
export * from './endpoints';
/* Files */
export * from './files';
/* Tools */
export * from './tools';
/* web search */
export * from './web';
/* types */

View File

@@ -3,7 +3,7 @@ import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol
import type { OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js';
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type { TokenMethods } from '@librechat/data-schemas';
import type { TUser } from 'librechat-data-provider';
import type { TUser, RequestBody } from 'librechat-data-provider';
import type { MCPOAuthTokens, MCPOAuthFlowMetadata } from './oauth/types';
import type { FlowStateManager } from '~/flow/manager';
import type { JsonSchemaType } from '~/types/zod';
@@ -373,6 +373,7 @@ export class MCPManager {
oauthEnd,
signal,
returnOnOAuth = false,
body,
}: {
user: TUser;
serverName: string;
@@ -383,6 +384,7 @@ export class MCPManager {
oauthEnd?: () => Promise<void>;
signal?: AbortSignal;
returnOnOAuth?: boolean;
body?: RequestBody;
}): Promise<MCPConnection> {
const userId = user.id;
if (!userId) {
@@ -432,7 +434,7 @@ export class MCPManager {
);
}
config = { ...(processMCPEnv(config, user, customUserVars) ?? {}) };
config = { ...(processMCPEnv(config, user, customUserVars, body) ?? {}) };
/** If no in-memory tokens, tokens from persistent storage */
let tokens: MCPOAuthTokens | null = null;
if (tokenMethods?.findToken) {
@@ -859,6 +861,7 @@ export class MCPManager {
oauthStart,
oauthEnd,
customUserVars,
body,
}: {
user?: TUser;
serverName: string;
@@ -871,6 +874,7 @@ export class MCPManager {
flowManager: FlowStateManager<MCPOAuthTokens | null>;
oauthStart?: (authURL: string) => Promise<void>;
oauthEnd?: () => Promise<void>;
body?: RequestBody;
}): Promise<t.FormattedToolResponse> {
/** User-specific connection */
let connection: MCPConnection | undefined;
@@ -890,6 +894,7 @@ export class MCPManager {
oauthEnd,
signal: options?.signal,
customUserVars,
body,
});
} else {
/** App-level connection */

View File

@@ -0,0 +1,461 @@
import { AuthType, Constants, EToolResources } from 'librechat-data-provider';
import type { TPlugin, FunctionTool, TCustomConfig } from 'librechat-data-provider';
import {
convertMCPToolsToPlugins,
filterUniquePlugins,
checkPluginAuth,
getToolkitKey,
} from './format';
describe('format.ts helper functions', () => {
describe('filterUniquePlugins', () => {
it('should return empty array when plugins is undefined', () => {
const result = filterUniquePlugins(undefined);
expect(result).toEqual([]);
});
it('should return empty array when plugins is empty', () => {
const result = filterUniquePlugins([]);
expect(result).toEqual([]);
});
it('should filter out duplicate plugins based on pluginKey', () => {
const plugins: TPlugin[] = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First plugin' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second plugin' },
{ name: 'Plugin1 Duplicate', pluginKey: 'key1', description: 'Duplicate of first' },
{ name: 'Plugin3', pluginKey: 'key3', description: 'Third plugin' },
];
const result = filterUniquePlugins(plugins);
expect(result).toHaveLength(3);
expect(result[0].pluginKey).toBe('key1');
expect(result[1].pluginKey).toBe('key2');
expect(result[2].pluginKey).toBe('key3');
// The first occurrence should be kept
expect(result[0].name).toBe('Plugin1');
});
it('should handle plugins with identical data', () => {
const plugin: TPlugin = { name: 'Plugin', pluginKey: 'key', description: 'Test' };
const plugins: TPlugin[] = [plugin, plugin, plugin];
const result = filterUniquePlugins(plugins);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(plugin);
});
});
describe('checkPluginAuth', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should return false when plugin is undefined', () => {
const result = checkPluginAuth(undefined);
expect(result).toBe(false);
});
it('should return false when authConfig is undefined', () => {
const plugin: TPlugin = { name: 'Test', pluginKey: 'test', description: 'Test plugin' };
const result = checkPluginAuth(plugin);
expect(result).toBe(false);
});
it('should return false when authConfig is empty array', () => {
const plugin: TPlugin = {
name: 'Test',
pluginKey: 'test',
description: 'Test plugin',
authConfig: [],
};
const result = checkPluginAuth(plugin);
expect(result).toBe(false);
});
it('should return true when all required auth fields have valid env values', () => {
process.env.API_KEY = 'valid-key';
process.env.SECRET_KEY = 'valid-secret';
const plugin: TPlugin = {
name: 'Test',
pluginKey: 'test',
description: 'Test plugin',
authConfig: [
{ authField: 'API_KEY', label: 'API Key', description: 'API Key' },
{ authField: 'SECRET_KEY', label: 'Secret Key', description: 'Secret Key' },
],
};
const result = checkPluginAuth(plugin);
expect(result).toBe(true);
});
it('should return false when any required auth field is missing', () => {
process.env.API_KEY = 'valid-key';
// SECRET_KEY is not set
const plugin: TPlugin = {
name: 'Test',
pluginKey: 'test',
description: 'Test plugin',
authConfig: [
{ authField: 'API_KEY', label: 'API Key', description: 'API Key' },
{ authField: 'SECRET_KEY', label: 'Secret Key', description: 'Secret Key' },
],
};
const result = checkPluginAuth(plugin);
expect(result).toBe(false);
});
it('should return false when auth field value is empty string', () => {
process.env.API_KEY = '';
const plugin: TPlugin = {
name: 'Test',
pluginKey: 'test',
description: 'Test plugin',
authConfig: [{ authField: 'API_KEY', label: 'API Key', description: 'API Key' }],
};
const result = checkPluginAuth(plugin);
expect(result).toBe(false);
});
it('should return false when auth field value is whitespace only', () => {
process.env.API_KEY = ' ';
const plugin: TPlugin = {
name: 'Test',
pluginKey: 'test',
description: 'Test plugin',
authConfig: [{ authField: 'API_KEY', label: 'API Key', description: 'API Key' }],
};
const result = checkPluginAuth(plugin);
expect(result).toBe(false);
});
it('should return false when auth field value is USER_PROVIDED', () => {
process.env.API_KEY = AuthType.USER_PROVIDED;
const plugin: TPlugin = {
name: 'Test',
pluginKey: 'test',
description: 'Test plugin',
authConfig: [{ authField: 'API_KEY', label: 'API Key', description: 'API Key' }],
};
const result = checkPluginAuth(plugin);
expect(result).toBe(false);
});
it('should handle alternate auth fields with || separator', () => {
process.env.ALTERNATE_KEY = 'valid-key';
const plugin: TPlugin = {
name: 'Test',
pluginKey: 'test',
description: 'Test plugin',
authConfig: [
{ authField: 'PRIMARY_KEY||ALTERNATE_KEY', label: 'API Key', description: 'API Key' },
],
};
const result = checkPluginAuth(plugin);
expect(result).toBe(true);
});
it('should return true when at least one alternate auth field is valid', () => {
process.env.PRIMARY_KEY = '';
process.env.ALTERNATE_KEY = 'valid-key';
process.env.THIRD_KEY = AuthType.USER_PROVIDED;
const plugin: TPlugin = {
name: 'Test',
pluginKey: 'test',
description: 'Test plugin',
authConfig: [
{
authField: 'PRIMARY_KEY||ALTERNATE_KEY||THIRD_KEY',
label: 'API Key',
description: 'API Key',
},
],
};
const result = checkPluginAuth(plugin);
expect(result).toBe(true);
});
});
describe('convertMCPToolsToPlugins', () => {
it('should return undefined when functionTools is undefined', () => {
const result = convertMCPToolsToPlugins({ functionTools: undefined });
expect(result).toBeUndefined();
});
it('should return undefined when functionTools is not an object', () => {
const result = convertMCPToolsToPlugins({
functionTools: 'not-an-object' as unknown as Record<string, FunctionTool>,
});
expect(result).toBeUndefined();
});
it('should return empty array when functionTools is empty object', () => {
const result = convertMCPToolsToPlugins({ functionTools: {} });
expect(result).toEqual([]);
});
it('should skip entries without function property', () => {
const functionTools: Record<string, FunctionTool> = {
tool1: { type: 'function' } as FunctionTool,
tool2: { function: { name: 'tool2', description: 'Tool 2' } } as FunctionTool,
};
const result = convertMCPToolsToPlugins({ functionTools });
expect(result).toHaveLength(0); // tool2 doesn't have mcp_delimiter in key
});
it('should skip entries without mcp_delimiter in key', () => {
const functionTools: Record<string, FunctionTool> = {
'regular-tool': {
type: 'function',
function: { name: 'regular-tool', description: 'Regular tool' },
} as FunctionTool,
};
const result = convertMCPToolsToPlugins({ functionTools });
expect(result).toHaveLength(0);
});
it('should convert MCP tools to plugins correctly', () => {
const functionTools: Record<string, FunctionTool> = {
[`tool1${Constants.mcp_delimiter}server1`]: {
type: 'function',
function: { name: 'tool1', description: 'Tool 1 description' },
} as FunctionTool,
};
const result = convertMCPToolsToPlugins({ functionTools });
expect(result).toHaveLength(1);
expect(result![0]).toEqual({
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}server1`,
description: 'Tool 1 description',
authenticated: true,
icon: undefined,
authConfig: [],
});
});
it('should handle missing description', () => {
const functionTools: Record<string, FunctionTool> = {
[`tool1${Constants.mcp_delimiter}server1`]: {
type: 'function',
function: { name: 'tool1' },
} as FunctionTool,
};
const result = convertMCPToolsToPlugins({ functionTools });
expect(result).toHaveLength(1);
expect(result![0].description).toBe('');
});
it('should add icon from server config', () => {
const functionTools: Record<string, FunctionTool> = {
[`tool1${Constants.mcp_delimiter}server1`]: {
type: 'function',
function: { name: 'tool1', description: 'Tool 1' },
} as FunctionTool,
};
const customConfig: Partial<TCustomConfig> = {
mcpServers: {
server1: {
command: 'test',
args: [],
iconPath: '/path/to/icon.png',
},
},
};
const result = convertMCPToolsToPlugins({ functionTools, customConfig });
expect(result).toHaveLength(1);
expect(result![0].icon).toBe('/path/to/icon.png');
});
it('should handle customUserVars in server config', () => {
const functionTools: Record<string, FunctionTool> = {
[`tool1${Constants.mcp_delimiter}server1`]: {
type: 'function',
function: { name: 'tool1', description: 'Tool 1' },
} as FunctionTool,
};
const customConfig: Partial<TCustomConfig> = {
mcpServers: {
server1: {
command: 'test',
args: [],
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' },
SECRET: { title: 'Secret', description: 'Your secret' },
},
},
},
};
const result = convertMCPToolsToPlugins({ functionTools, customConfig });
expect(result).toHaveLength(1);
expect(result![0].authConfig).toHaveLength(2);
expect(result![0].authConfig).toEqual([
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
{ authField: 'SECRET', label: 'Secret', description: 'Your secret' },
]);
});
it('should use key as label when title is missing in customUserVars', () => {
const functionTools: Record<string, FunctionTool> = {
[`tool1${Constants.mcp_delimiter}server1`]: {
type: 'function',
function: { name: 'tool1', description: 'Tool 1' },
} as FunctionTool,
};
const customConfig: Partial<TCustomConfig> = {
mcpServers: {
server1: {
command: 'test',
args: [],
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' },
},
},
},
};
const result = convertMCPToolsToPlugins({ functionTools, customConfig });
expect(result).toHaveLength(1);
expect(result![0].authConfig).toEqual([
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
]);
});
it('should handle empty customUserVars', () => {
const functionTools: Record<string, FunctionTool> = {
[`tool1${Constants.mcp_delimiter}server1`]: {
type: 'function',
function: { name: 'tool1', description: 'Tool 1' },
} as FunctionTool,
};
const customConfig: Partial<TCustomConfig> = {
mcpServers: {
server1: {
command: 'test',
args: [],
customUserVars: {},
},
},
};
const result = convertMCPToolsToPlugins({ functionTools, customConfig });
expect(result).toHaveLength(1);
expect(result![0].authConfig).toEqual([]);
});
});
describe('getToolkitKey', () => {
it('should return undefined when toolName is undefined', () => {
const toolkits: TPlugin[] = [
{ name: 'Toolkit1', pluginKey: 'toolkit1', description: 'Test toolkit' },
];
const result = getToolkitKey({ toolkits, toolName: undefined });
expect(result).toBeUndefined();
});
it('should return undefined when toolName is empty string', () => {
const toolkits: TPlugin[] = [
{ name: 'Toolkit1', pluginKey: 'toolkit1', description: 'Test toolkit' },
];
const result = getToolkitKey({ toolkits, toolName: '' });
expect(result).toBeUndefined();
});
it('should return undefined when no matching toolkit is found', () => {
const toolkits: TPlugin[] = [
{ name: 'Toolkit1', pluginKey: 'toolkit1', description: 'Test toolkit' },
{ name: 'Toolkit2', pluginKey: 'toolkit2', description: 'Test toolkit' },
];
const result = getToolkitKey({ toolkits, toolName: 'nonexistent_tool' });
expect(result).toBeUndefined();
});
it('should match toolkit when toolName starts with pluginKey', () => {
const toolkits: TPlugin[] = [
{ name: 'Toolkit1', pluginKey: 'toolkit1', description: 'Test toolkit' },
{ name: 'Toolkit2', pluginKey: 'toolkit2', description: 'Test toolkit' },
];
const result = getToolkitKey({ toolkits, toolName: 'toolkit2_function' });
expect(result).toBe('toolkit2');
});
it('should handle image_edit tools with suffix matching', () => {
const toolkits: TPlugin[] = [
{ name: 'Image Editor', pluginKey: 'image_edit_v1', description: 'Image editing' },
{ name: 'Image Editor 2', pluginKey: 'image_edit_v2', description: 'Image editing v2' },
];
const result = getToolkitKey({
toolkits,
toolName: `${EToolResources.image_edit}_function_v2`,
});
expect(result).toBe('image_edit_v2');
});
it('should match the first toolkit when multiple matches are possible', () => {
const toolkits: TPlugin[] = [
{ name: 'Toolkit', pluginKey: 'toolkit', description: 'Base toolkit' },
{ name: 'Toolkit Extended', pluginKey: 'toolkit_extended', description: 'Extended' },
];
const result = getToolkitKey({ toolkits, toolName: 'toolkit_function' });
expect(result).toBe('toolkit');
});
it('should handle empty toolkits array', () => {
const toolkits: TPlugin[] = [];
const result = getToolkitKey({ toolkits, toolName: 'any_tool' });
expect(result).toBeUndefined();
});
it('should handle complex plugin keys with underscores', () => {
const toolkits: TPlugin[] = [
{
name: 'Complex Toolkit',
pluginKey: 'complex_toolkit_with_underscores',
description: 'Complex',
},
];
const result = getToolkitKey({
toolkits,
toolName: 'complex_toolkit_with_underscores_function',
});
expect(result).toBe('complex_toolkit_with_underscores');
});
});
});

View File

@@ -0,0 +1,142 @@
import { AuthType, Constants, EToolResources } from 'librechat-data-provider';
import type { TCustomConfig, TPlugin, FunctionTool } from 'librechat-data-provider';
/**
* Filters out duplicate plugins from the list of plugins.
*
* @param plugins The list of plugins to filter.
* @returns The list of plugins with duplicates removed.
*/
export const filterUniquePlugins = (plugins?: TPlugin[]): TPlugin[] => {
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 plugin The plugin object containing the authentication configuration.
* @returns True if the plugin is authenticated for all required fields, false otherwise.
*/
export const checkPluginAuth = (plugin?: TPlugin): boolean => {
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;
});
};
/**
* Converts MCP function format tools to plugin format
* @param functionTools - Object with function format tools
* @param customConfig - Custom configuration for MCP servers
* @returns Array of plugin objects
*/
export function convertMCPToolsToPlugins({
functionTools,
customConfig,
}: {
functionTools?: Record<string, FunctionTool>;
customConfig?: Partial<TCustomConfig> | null;
}): TPlugin[] | undefined {
if (!functionTools || typeof functionTools !== 'object') {
return;
}
const plugins: TPlugin[] = [];
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: TPlugin = {
/** Tool name without server suffix */
name: parts[0],
pluginKey: toolKey,
description: functionData.description || '',
authenticated: true,
icon: serverConfig?.iconPath,
};
if (!serverConfig?.customUserVars) {
/** `authConfig` for MCP tools */
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;
}
/**
* @param toolkits
* @param toolName
* @returns toolKey
*/
export function getToolkitKey({
toolkits,
toolName,
}: {
toolkits: TPlugin[];
toolName?: string;
}): string | undefined {
let toolkitKey: string | undefined;
if (!toolName) {
return 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;
}

View File

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

View File

@@ -2,7 +2,7 @@ import { z } from 'zod';
import { openAISchema, EModelEndpoint } from 'librechat-data-provider';
import type { TEndpointOption, TAzureConfig, TEndpoint } from 'librechat-data-provider';
import type { BindToolsInput } from '@langchain/core/language_models/chat_models';
import type { OpenAIClientOptions } from '@librechat/agents';
import type { OpenAIClientOptions, Providers } from '@librechat/agents';
import type { AzureOptions } from './azure';
export type OpenAIParameters = z.infer<typeof openAISchema>;
@@ -35,6 +35,7 @@ export interface LLMConfigResult {
llmConfig: ClientOptions;
configOptions: OpenAIConfiguration;
tools?: BindToolsInput[];
provider?: Providers;
}
/**

View File

@@ -36,12 +36,14 @@ describe('resolveHeaders', () => {
});
it('should return empty object when headers is null', () => {
const result = resolveHeaders(null as unknown as Record<string, string> | undefined);
const result = resolveHeaders({
headers: null as unknown as Record<string, string>,
});
expect(result).toEqual({});
});
it('should return empty object when headers is empty', () => {
const result = resolveHeaders({});
const result = resolveHeaders({ headers: {} });
expect(result).toEqual({});
});
@@ -52,7 +54,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers);
const result = resolveHeaders({ headers });
expect(result).toEqual({
Authorization: 'test-api-key-value',
@@ -68,7 +70,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Id': 'test-user-123',
@@ -82,7 +84,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers);
const result = resolveHeaders({ headers });
expect(result).toEqual({
'User-Id': '{{LIBRECHAT_USER_ID}}',
@@ -97,7 +99,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Id': '{{LIBRECHAT_USER_ID}}',
@@ -123,7 +125,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Email': 'test@example.com',
@@ -148,7 +150,7 @@ describe('resolveHeaders', () => {
'Non-Existent': '{{LIBRECHAT_USER_NONEXISTENT}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Email': 'test@example.com',
@@ -171,7 +173,7 @@ describe('resolveHeaders', () => {
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
};
const result = resolveHeaders(headers, user, customUserVars);
const result = resolveHeaders({ headers, user, customUserVars });
expect(result).toEqual({
Authorization: 'Bearer user-specific-token',
@@ -194,7 +196,7 @@ describe('resolveHeaders', () => {
'Test-Email': '{{LIBRECHAT_USER_EMAIL}}',
};
const result = resolveHeaders(headers, user, customUserVars);
const result = resolveHeaders({ headers, user, customUserVars });
expect(result).toEqual({
'Test-Email': 'custom-email@example.com',
@@ -213,7 +215,7 @@ describe('resolveHeaders', () => {
'User-Id': '{{LIBRECHAT_USER_ID}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Role': 'admin',
@@ -233,7 +235,7 @@ describe('resolveHeaders', () => {
'Backup-Email': '{{LIBRECHAT_USER_EMAIL}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'Primary-Email': 'test@example.com',
@@ -259,7 +261,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers, user, customUserVars);
const result = resolveHeaders({ headers, user, customUserVars });
expect(result).toEqual({
Authorization: 'Bearer secret-token',
@@ -277,7 +279,7 @@ describe('resolveHeaders', () => {
};
const user = { id: 'user-123' };
const result = resolveHeaders(originalHeaders, user);
const result = resolveHeaders({ headers: originalHeaders, user });
// Verify the result is processed
expect(result).toEqual({
@@ -306,7 +308,7 @@ describe('resolveHeaders', () => {
'Dot-Header': '{{CUSTOM.VAR}}',
};
const result = resolveHeaders(headers, user, customUserVars);
const result = resolveHeaders({ headers, user, customUserVars });
expect(result).toEqual({
'Dash-Header': 'dash-value',
@@ -357,7 +359,7 @@ describe('resolveHeaders', () => {
'X-User-TermsAccepted': '{{LIBRECHAT_USER_TERMSACCEPTED}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result['X-User-ID']).toBe('abc');
expect(result['X-User-Name']).toBe('Test User');
@@ -384,7 +386,7 @@ describe('resolveHeaders', () => {
'X-Multi': 'User: {{LIBRECHAT_USER_ID}}, Env: ${TEST_API_KEY}, Custom: {{MY_CUSTOM}}',
};
const customVars = { MY_CUSTOM: 'custom-value' };
const result = resolveHeaders(headers, user, customVars);
const result = resolveHeaders({ headers, user, customUserVars: customVars });
expect(result['X-Multi']).toBe('User: abc, Env: test-api-key-value, Custom: custom-value');
});
@@ -394,7 +396,7 @@ describe('resolveHeaders', () => {
'X-Unknown': '{{SOMETHING_NOT_RECOGNIZED}}',
'X-Known': '{{LIBRECHAT_USER_ID}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result['X-Unknown']).toBe('{{SOMETHING_NOT_RECOGNIZED}}');
expect(result['X-Known']).toBe('abc');
});
@@ -416,7 +418,7 @@ describe('resolveHeaders', () => {
'X-Boolean': '{{LIBRECHAT_USER_EMAILVERIFIED}}',
};
const customVars = { MY_CUSTOM: 'custom-value' };
const result = resolveHeaders(headers, user, customVars);
const result = resolveHeaders({ headers, user, customUserVars: customVars });
expect(result['X-User']).toBe('abc');
expect(result['X-Env']).toBe('test-api-key-value');
@@ -426,4 +428,15 @@ describe('resolveHeaders', () => {
expect(result['X-Empty']).toBe('');
expect(result['X-Boolean']).toBe('true');
});
it('should process LIBRECHAT_BODY placeholders', () => {
const body = {
conversationId: 'conv-123',
parentMessageId: 'parent-456',
messageId: 'msg-789',
};
const headers = { 'X-Conversation': '{{LIBRECHAT_BODY_CONVERSATIONID}}' };
const result = resolveHeaders({ headers, body });
expect(result['X-Conversation']).toBe('conv-123');
});
});

View File

@@ -1,5 +1,5 @@
import { extractEnvVariable } from 'librechat-data-provider';
import type { TUser, MCPOptions } from 'librechat-data-provider';
import type { TUser, MCPOptions, RequestBody } from 'librechat-data-provider';
/**
* List of allowed user fields that can be used in MCP environment variables.
@@ -25,6 +25,12 @@ const ALLOWED_USER_FIELDS = [
'termsAccepted',
] as const;
/**
* List of allowed request body fields that can be used in header placeholders.
* These are common fields from the request body that are safe to expose in headers.
*/
const ALLOWED_BODY_FIELDS = ['conversationId', 'parentMessageId', 'messageId'] as const;
/**
* Processes a string value to replace user field placeholders
* @param value - The string value to process
@@ -61,21 +67,48 @@ function processUserPlaceholders(value: string, user?: TUser): string {
return value;
}
/**
* Replaces request body field placeholders within a string.
* Recognized placeholders: `{{LIBRECHAT_BODY_<FIELD>}}` where `<FIELD>` ∈ ALLOWED_BODY_FIELDS.
* If a body field is absent or null/undefined, it is replaced with an empty string.
*
* @param value - The string value to process
* @param body - The request body object
* @returns The processed string with placeholders replaced
*/
function processBodyPlaceholders(value: string, body: RequestBody): string {
for (const field of ALLOWED_BODY_FIELDS) {
const placeholder = `{{LIBRECHAT_BODY_${field.toUpperCase()}}}`;
if (!value.includes(placeholder)) {
continue;
}
const fieldValue = body[field];
const replacementValue = fieldValue == null ? '' : String(fieldValue);
value = value.replace(new RegExp(placeholder, 'g'), replacementValue);
}
return value;
}
/**
* Processes a single string value by replacing various types of placeholders
* @param originalValue - The original string value to process
* @param customUserVars - Optional custom user variables to replace placeholders
* @param user - Optional user object for replacing user field placeholders
* @param body - Optional request body object for replacing body field placeholders
* @returns The processed string with all placeholders replaced
*/
function processSingleValue({
originalValue,
customUserVars,
user,
body = undefined,
}: {
originalValue: string;
customUserVars?: Record<string, string>;
user?: TUser;
body?: RequestBody;
}): string {
let value = originalValue;
@@ -92,7 +125,12 @@ function processSingleValue({
// 2. Replace user field placeholders (e.g., {{LIBRECHAT_USER_EMAIL}}, {{LIBRECHAT_USER_ID}})
value = processUserPlaceholders(value, user);
// 3. Replace system environment variables
// 3. Replace body field placeholders (e.g., {{LIBRECHAT_BODY_CONVERSATIONID}}, {{LIBRECHAT_BODY_PARENTMESSAGEID}})
if (body) {
value = processBodyPlaceholders(value, body);
}
// 4. Replace system environment variables
value = extractEnvVariable(value);
return value;
@@ -103,12 +141,14 @@ function processSingleValue({
* @param obj - The object to process
* @param user - The user object containing all user fields
* @param customUserVars - vars that user set in settings
* @param body - the body of the request that is being processed
* @returns - The processed object with environment variables replaced
*/
export function processMCPEnv(
obj: Readonly<MCPOptions>,
user?: TUser,
customUserVars?: Record<string, string>,
body?: RequestBody,
): MCPOptions {
if (obj === null || obj === undefined) {
return obj;
@@ -119,7 +159,7 @@ export function processMCPEnv(
if ('env' in newObj && newObj.env) {
const processedEnv: Record<string, string> = {};
for (const [key, originalValue] of Object.entries(newObj.env)) {
processedEnv[key] = processSingleValue({ originalValue, customUserVars, user });
processedEnv[key] = processSingleValue({ originalValue, customUserVars, user, body });
}
newObj.env = processedEnv;
}
@@ -127,7 +167,7 @@ export function processMCPEnv(
if ('args' in newObj && newObj.args) {
const processedArgs: string[] = [];
for (const originalValue of newObj.args) {
processedArgs.push(processSingleValue({ originalValue, customUserVars, user }));
processedArgs.push(processSingleValue({ originalValue, customUserVars, user, body }));
}
newObj.args = processedArgs;
}
@@ -137,39 +177,47 @@ export function processMCPEnv(
if ('headers' in newObj && newObj.headers) {
const processedHeaders: Record<string, string> = {};
for (const [key, originalValue] of Object.entries(newObj.headers)) {
processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user });
processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user, body });
}
newObj.headers = processedHeaders;
}
// Process URL if it exists (for WebSocket, SSE, StreamableHTTP types)
if ('url' in newObj && newObj.url) {
newObj.url = processSingleValue({ originalValue: newObj.url, customUserVars, user });
newObj.url = processSingleValue({ originalValue: newObj.url, customUserVars, user, body });
}
return newObj;
}
/**
* Resolves header values by replacing user placeholders, custom variables, and environment variables
* @param headers - The headers object to process
* @param user - Optional user object for replacing user field placeholders (can be partial with just id)
* @param customUserVars - Optional custom user variables to replace placeholders
* @returns - The processed headers with all placeholders replaced
* Resolves header values by replacing user placeholders, body variables, custom variables, and environment variables.
*
* @param options - Optional configuration object.
* @param options.headers - The headers object to process.
* @param options.user - Optional user object for replacing user field placeholders (can be partial with just id).
* @param options.body - Optional request body object for replacing body field placeholders.
* @param options.customUserVars - Optional custom user variables to replace placeholders.
* @returns The processed headers with all placeholders replaced.
*/
export function resolveHeaders(
headers: Record<string, string> | undefined,
user?: Partial<TUser> | { id: string },
customUserVars?: Record<string, string>,
) {
const resolvedHeaders = { ...(headers ?? {}) };
export function resolveHeaders(options?: {
headers: Record<string, string> | undefined;
user?: Partial<TUser> | { id: string };
body?: RequestBody;
customUserVars?: Record<string, string>;
}) {
const { headers, user, body, customUserVars } = options ?? {};
const inputHeaders = headers ?? {};
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
Object.keys(headers).forEach((key) => {
const resolvedHeaders: Record<string, string> = { ...inputHeaders };
if (inputHeaders && typeof inputHeaders === 'object' && !Array.isArray(inputHeaders)) {
Object.keys(inputHeaders).forEach((key) => {
resolvedHeaders[key] = processSingleValue({
originalValue: headers[key],
originalValue: inputHeaders[key],
customUserVars,
user: user as TUser,
body,
});
});
}

View File

@@ -0,0 +1,26 @@
/**
* Normalizes an error-like object into an HTTP status and message.
* Ensures we always respond with a valid numeric status to avoid UI hangs.
*/
export function normalizeHttpError(
err: Error | { status?: number; message?: string } | unknown,
fallbackStatus = 400,
) {
let status = fallbackStatus;
if (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number') {
status = err.status;
}
let message = 'An error occurred.';
if (
err &&
typeof err === 'object' &&
'message' in err &&
typeof err.message === 'string' &&
err.message.length > 0
) {
message = err.message;
}
return { status, message };
}

View File

@@ -12,3 +12,4 @@ export * from './openid';
export * from './tempChatRetention';
export { default as Tokenizer } from './tokenizer';
export * from './yaml';
export * from './http';

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/client",
"version": "0.2.3",
"version": "0.2.4",
"description": "React components for LibreChat",
"main": "dist/index.js",
"module": "dist/index.es.js",
@@ -54,6 +54,7 @@
"@react-spring/web": "^10.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.2.6",
"framer-motion": "^12.23.6",
"i18next-browser-languagedetector": "^8.2.0",
"input-otp": "^1.4.2",

View File

@@ -11,6 +11,16 @@
line-height: 1.5rem;
color: black;
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.25);
/* Enhanced layout for longer descriptions */
max-width: 320px;
word-wrap: break-word;
text-align: left;
}
@media (max-width: 640px) {
.tooltip {
max-width: 200px;
}
}
.tooltip:where(.dark, .dark *) {

View File

@@ -1,6 +1,7 @@
import DOMPurify from 'dompurify';
import * as Ariakit from '@ariakit/react';
import { forwardRef, useId, useMemo } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { forwardRef, useMemo } from 'react';
import { cn } from '~/utils';
import './Tooltip.css';
@@ -8,18 +9,47 @@ interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps {
description: string;
side?: 'top' | 'bottom' | 'left' | 'right';
className?: string;
focusable?: boolean;
role?: string;
enableHTML?: boolean;
}
export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(
{ description, side = 'top', className, role, ...props },
{ description, side = 'top', className, role, enableHTML = false, ...props },
ref,
) {
const tooltip = Ariakit.useTooltipStore({ placement: side });
const mounted = Ariakit.useStoreState(tooltip, (state) => state.mounted);
const placement = Ariakit.useStoreState(tooltip, (state) => state.placement);
const id = useId();
const sanitizer = useMemo(() => {
const instance = DOMPurify();
instance.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName && node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
return instance;
}, []);
const sanitizedHTML = useMemo(() => {
if (!enableHTML) {
return '';
}
try {
return sanitizer.sanitize(description, {
ALLOWED_TAGS: ['a', 'strong', 'b', 'em', 'i', 'br', 'code'],
ALLOWED_ATTR: ['href', 'class', 'target', 'rel'],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
});
} catch (error) {
console.error('Sanitization failed', error);
return description;
}
}, [enableHTML, description, sanitizer]);
const { x, y } = useMemo(() => {
const dir = placement.split('-')[0];
switch (dir) {
@@ -49,6 +79,7 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
{...props}
ref={ref}
role={role}
aria-describedby={id}
onKeyDown={handleKeyDown}
className={cn('cursor-pointer', className)}
/>
@@ -58,6 +89,7 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
gutter={4}
alwaysVisible
className="tooltip"
id={id}
render={
<motion.div
initial={{ opacity: 0, x, y }}
@@ -67,7 +99,15 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
}
>
<Ariakit.TooltipArrow />
{description}
{enableHTML ? (
<div
dangerouslySetInnerHTML={{
__html: sanitizedHTML,
}}
/>
) : (
description
)}
</Ariakit.Tooltip>
)}
</AnimatePresence>

View File

@@ -1,6 +1,6 @@
{
"name": "librechat-data-provider",
"version": "0.7.903",
"version": "0.8.001",
"description": "data services for librechat apps",
"main": "dist/index.js",
"module": "dist/index.es.js",

View File

@@ -27,6 +27,7 @@ export * from './types/mutations';
export * from './types/queries';
export * from './types/runs';
export * from './types/web';
export * from './types/http';
/* query/mutation keys */
export * from './keys';
/* api call helpers */

View File

@@ -1,4 +1,5 @@
import {
Verbosity,
ImageDetail,
EModelEndpoint,
openAISettings,
@@ -221,12 +222,14 @@ const openAIParams: Record<string, SettingDefinition> = {
component: 'slider',
options: [
ReasoningEffort.none,
ReasoningEffort.minimal,
ReasoningEffort.low,
ReasoningEffort.medium,
ReasoningEffort.high,
],
enumMappings: {
[ReasoningEffort.none]: 'com_ui_none',
[ReasoningEffort.minimal]: 'com_ui_minimal',
[ReasoningEffort.low]: 'com_ui_low',
[ReasoningEffort.medium]: 'com_ui_medium',
[ReasoningEffort.high]: 'com_ui_high',
@@ -284,6 +287,25 @@ const openAIParams: Record<string, SettingDefinition> = {
optionType: 'model',
columnSpan: 4,
},
verbosity: {
key: 'verbosity',
label: 'com_endpoint_verbosity',
labelCode: true,
description: 'com_endpoint_openai_verbosity',
descriptionCode: true,
type: 'enum',
default: Verbosity.none,
component: 'slider',
options: [Verbosity.none, Verbosity.low, Verbosity.medium, Verbosity.high],
enumMappings: {
[Verbosity.none]: 'com_ui_none',
[Verbosity.low]: 'com_ui_low',
[Verbosity.medium]: 'com_ui_medium',
[Verbosity.high]: 'com_ui_high',
},
optionType: 'model',
columnSpan: 4,
},
disableStreaming: {
key: 'disableStreaming',
label: 'com_endpoint_disable_streaming_label',
@@ -639,6 +661,7 @@ const openAI: SettingsConfiguration = [
openAIParams.reasoning_effort,
openAIParams.useResponsesApi,
openAIParams.reasoning_summary,
openAIParams.verbosity,
openAIParams.disableStreaming,
];
@@ -660,6 +683,7 @@ const openAICol2: SettingsConfiguration = [
baseDefinitions.imageDetail,
openAIParams.reasoning_effort,
openAIParams.reasoning_summary,
openAIParams.verbosity,
openAIParams.useResponsesApi,
openAIParams.web_search,
openAIParams.disableStreaming,

View File

@@ -18,7 +18,6 @@ import {
compactAssistantSchema,
} from './schemas';
import { bedrockInputSchema } from './bedrock';
import { extractEnvVariable } from './utils';
import { alternateName } from './config';
type EndpointSchema =

View File

@@ -113,6 +113,7 @@ export enum ImageDetail {
export enum ReasoningEffort {
none = '',
minimal = 'minimal',
low = 'low',
medium = 'medium',
high = 'high',
@@ -125,6 +126,13 @@ export enum ReasoningSummary {
detailed = 'detailed',
}
export enum Verbosity {
none = '',
low = 'low',
medium = 'medium',
high = 'high',
}
export const imageDetailNumeric = {
[ImageDetail.low]: 0,
[ImageDetail.auto]: 1,
@@ -140,6 +148,7 @@ export const imageDetailValue = {
export const eImageDetailSchema = z.nativeEnum(ImageDetail);
export const eReasoningEffortSchema = z.nativeEnum(ReasoningEffort);
export const eReasoningSummarySchema = z.nativeEnum(ReasoningSummary);
export const eVerbositySchema = z.nativeEnum(Verbosity);
export const defaultAssistantFormValues = {
assistant: '',
@@ -208,13 +217,13 @@ export const openAISettings = {
default: 1 as const,
},
presence_penalty: {
min: 0 as const,
min: -2 as const,
max: 2 as const,
step: 0.01 as const,
default: 0 as const,
},
frequency_penalty: {
min: 0 as const,
min: -2 as const,
max: 2 as const,
step: 0.01 as const,
default: 0 as const,
@@ -374,13 +383,13 @@ export const agentsSettings = {
default: 1 as const,
},
presence_penalty: {
min: 0 as const,
min: -2 as const,
max: 2 as const,
step: 0.01 as const,
default: 0 as const,
},
frequency_penalty: {
min: 0 as const,
min: -2 as const,
max: 2 as const,
step: 0.01 as const,
default: 0 as const,
@@ -635,6 +644,8 @@ export const tConversationSchema = z.object({
/* OpenAI: Reasoning models only */
reasoning_effort: eReasoningEffortSchema.optional().nullable(),
reasoning_summary: eReasoningSummarySchema.optional().nullable(),
/* OpenAI: Verbosity control */
verbosity: eVerbositySchema.optional().nullable(),
/* OpenAI: use Responses API */
useResponsesApi: z.boolean().optional(),
/* OpenAI Responses API / Anthropic API / Google API */
@@ -742,6 +753,8 @@ export const tQueryParamsSchema = tConversationSchema
/** @endpoints openAI, custom, azureOpenAI */
reasoning_summary: true,
/** @endpoints openAI, custom, azureOpenAI */
verbosity: true,
/** @endpoints openAI, custom, azureOpenAI */
useResponsesApi: true,
/** @endpoints openAI, anthropic, google */
web_search: true,
@@ -1077,6 +1090,7 @@ export const openAIBaseSchema = tConversationSchema.pick({
max_tokens: true,
reasoning_effort: true,
reasoning_summary: true,
verbosity: true,
useResponsesApi: true,
web_search: true,
disableStreaming: true,

View File

@@ -40,6 +40,7 @@ export type TEndpointOption = Pick<
| 'resendFiles'
| 'imageDetail'
| 'reasoning_effort'
| 'verbosity'
| 'instructions'
| 'additional_instructions'
| 'append_current_datetime'

View File

@@ -0,0 +1,6 @@
export interface RequestBody {
parentMessageId: string;
messageId: string;
conversationId?: string;
[key: string]: unknown;
}

View File

@@ -136,12 +136,7 @@ export type DuplicateVersionError = Error & {
};
};
export type UpdateAgentMutationOptions = MutationOptions<
Agent,
UpdateAgentVariables,
unknown,
DuplicateVersionError
>;
export type UpdateAgentMutationOptions = MutationOptions<Agent, UpdateAgentVariables>;
export type DuplicateAgentBody = {
agent_id: string;

View File

@@ -10,15 +10,20 @@ import type {
// Factory function that takes mongoose instance and returns the methods
export function createPluginAuthMethods(mongoose: typeof import('mongoose')) {
/**
* Finds a single plugin auth entry by userId and authField
* Finds a single plugin auth entry by userId and authField (and optionally pluginKey)
*/
async function findOnePluginAuth({
userId,
authField,
pluginKey,
}: FindPluginAuthParams): Promise<IPluginAuth | null> {
try {
const PluginAuth: Model<IPluginAuth> = mongoose.models.PluginAuth;
return await PluginAuth.findOne({ userId, authField }).lean();
return await PluginAuth.findOne({
userId,
authField,
...(pluginKey && { pluginKey }),
}).lean();
} catch (error) {
throw new Error(
`Failed to find plugin auth: ${error instanceof Error ? error.message : 'Unknown error'}`,

View File

@@ -148,4 +148,8 @@ export const conversationPreset = {
reasoning_summary: {
type: String,
},
/** Verbosity control */
verbosity: {
type: String,
},
};

View File

@@ -47,6 +47,7 @@ export interface IPreset extends Document {
max_tokens?: number;
reasoning_effort?: string;
reasoning_summary?: string;
verbosity?: string;
useResponsesApi?: boolean;
web_search?: boolean;
disableStreaming?: boolean;

View File

@@ -46,6 +46,7 @@ export interface IConversation extends Document {
max_tokens?: number;
reasoning_effort?: string;
reasoning_summary?: string;
verbosity?: string;
useResponsesApi?: boolean;
web_search?: boolean;
disableStreaming?: boolean;

View File

@@ -18,6 +18,7 @@ export interface PluginAuthQuery {
export interface FindPluginAuthParams {
userId: string;
authField: string;
pluginKey?: string;
}
export interface FindPluginAuthsByKeysParams {