Compare commits
37 Commits
gh-pages
...
feat/add-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9d0442531 | ||
|
|
274987712c | ||
|
|
888e3a31cf | ||
|
|
21e00168b1 | ||
|
|
7ea23c5a7d | ||
|
|
f4833b6b25 | ||
|
|
d37db43e29 | ||
|
|
eec10bf745 | ||
|
|
3508839d6d | ||
|
|
a8babbcebf | ||
|
|
da3730b7d6 | ||
|
|
770c766d50 | ||
|
|
5eb6926464 | ||
|
|
e478ae1c28 | ||
|
|
0c9284c8ae | ||
|
|
4eeadddfe6 | ||
|
|
9ca1847535 | ||
|
|
5d0bc95193 | ||
|
|
e7d6100fe4 | ||
|
|
01a95229f2 | ||
|
|
0939250f07 | ||
|
|
7147bce3c3 | ||
|
|
486fe34a2b | ||
|
|
922f43f520 | ||
|
|
e6fa01d514 | ||
|
|
8238fb49e0 | ||
|
|
430557676d | ||
|
|
8a5047c456 | ||
|
|
c787515894 | ||
|
|
d95d8032cc | ||
|
|
b9f72f4869 | ||
|
|
429bb6653a | ||
|
|
47caafa8f8 | ||
|
|
8530594f37 | ||
|
|
0b071c06f6 | ||
|
|
1092392ed8 | ||
|
|
36c8947029 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,6 +13,9 @@ pids
|
||||
*.seed
|
||||
.git
|
||||
|
||||
# CI/CD data
|
||||
test-image*
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.' });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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```/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}.`);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -189,6 +189,7 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
||||
},
|
||||
oauthStart,
|
||||
oauthEnd,
|
||||
body: req.body,
|
||||
});
|
||||
|
||||
if (isAssistantsEndpoint(provider) && Array.isArray(result)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
46
client/src/Providers/ArtifactsContext.tsx
Normal file
46
client/src/Providers/ArtifactsContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
380
client/src/components/SidePanel/Agents/AgentPanel.test.tsx
Normal file
380
client/src/components/SidePanel/Agents/AgentPanel.test.tsx
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}` : ''
|
||||
|
||||
@@ -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>([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "היסטוריית גרסאות",
|
||||
|
||||
@@ -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の作成または更新にエラーが発生しました。",
|
||||
|
||||
@@ -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": "버전 기록",
|
||||
|
||||
@@ -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 128–32 768 žetonus. Gemini 2.5 Flash atbalsta 0–24 576 žetonus. Gemini 2.5 Flash Lite atbalsta 512–24 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",
|
||||
|
||||
@@ -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": "ข้ามคำแนะนำ",
|
||||
|
||||
@@ -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": "版本历史",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
28
index.yaml
28
index.yaml
@@ -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"
|
||||
@@ -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
83
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
424
packages/api/src/endpoints/openai/llm.spec.ts
Normal file
424
packages/api/src/endpoints/openai/llm.spec.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export * from './agents';
|
||||
export * from './endpoints';
|
||||
/* Files */
|
||||
export * from './files';
|
||||
/* Tools */
|
||||
export * from './tools';
|
||||
/* web search */
|
||||
export * from './web';
|
||||
/* types */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
461
packages/api/src/tools/format.spec.ts
Normal file
461
packages/api/src/tools/format.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
142
packages/api/src/tools/format.ts
Normal file
142
packages/api/src/tools/format.ts
Normal 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;
|
||||
}
|
||||
1
packages/api/src/tools/index.ts
Normal file
1
packages/api/src/tools/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './format';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
26
packages/api/src/utils/http.ts
Normal file
26
packages/api/src/utils/http.ts
Normal 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 };
|
||||
}
|
||||
@@ -12,3 +12,4 @@ export * from './openid';
|
||||
export * from './tempChatRetention';
|
||||
export { default as Tokenizer } from './tokenizer';
|
||||
export * from './yaml';
|
||||
export * from './http';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 *) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
compactAssistantSchema,
|
||||
} from './schemas';
|
||||
import { bedrockInputSchema } from './bedrock';
|
||||
import { extractEnvVariable } from './utils';
|
||||
import { alternateName } from './config';
|
||||
|
||||
type EndpointSchema =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,6 +40,7 @@ export type TEndpointOption = Pick<
|
||||
| 'resendFiles'
|
||||
| 'imageDetail'
|
||||
| 'reasoning_effort'
|
||||
| 'verbosity'
|
||||
| 'instructions'
|
||||
| 'additional_instructions'
|
||||
| 'append_current_datetime'
|
||||
|
||||
6
packages/data-provider/src/types/http.ts
Normal file
6
packages/data-provider/src/types/http.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface RequestBody {
|
||||
parentMessageId: string;
|
||||
messageId: string;
|
||||
conversationId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'}`,
|
||||
|
||||
@@ -148,4 +148,8 @@ export const conversationPreset = {
|
||||
reasoning_summary: {
|
||||
type: String,
|
||||
},
|
||||
/** Verbosity control */
|
||||
verbosity: {
|
||||
type: String,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface PluginAuthQuery {
|
||||
export interface FindPluginAuthParams {
|
||||
userId: string;
|
||||
authField: string;
|
||||
pluginKey?: string;
|
||||
}
|
||||
|
||||
export interface FindPluginAuthsByKeysParams {
|
||||
|
||||
Reference in New Issue
Block a user