Compare commits
36 Commits
style/mark
...
v0.8.0-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
007570b5c6 | ||
|
|
5943d5346c | ||
|
|
052e61b735 | ||
|
|
1ccac58403 | ||
|
|
04d74a7e07 | ||
|
|
0fdca8ddbd | ||
|
|
c5ca621efd | ||
|
|
8cefa566da | ||
|
|
7e4c8a5d0d | ||
|
|
edf33bedcb | ||
|
|
21e00168b1 | ||
|
|
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 |
53
.github/workflows/helmcharts.yml
vendored
53
.github/workflows/helmcharts.yml
vendored
@@ -4,12 +4,13 @@ name: Build Helm Charts on Tag
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
- "chart-*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -26,15 +27,49 @@ jobs:
|
||||
uses: azure/setup-helm@v4
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Build Subchart Deps
|
||||
run: |
|
||||
cd helm/librechat-rag-api
|
||||
helm dependency build
|
||||
cd helm/librechat
|
||||
helm dependency build
|
||||
cd ../librechat-rag-api
|
||||
helm dependency build
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@v1.6.0
|
||||
- name: Get Chart Version
|
||||
id: chart-version
|
||||
run: |
|
||||
CHART_VERSION=$(echo "${{ github.ref_name }}" | cut -d'-' -f2)
|
||||
echo "CHART_VERSION=${CHART_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Log in to GitHub Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
charts_dir: helm
|
||||
skip_existing: true
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Run Helm OCI Charts Releaser
|
||||
# This is for the librechat chart
|
||||
- name: Release Helm OCI Charts for librechat
|
||||
uses: appany/helm-oci-chart-releaser@v0.4.2
|
||||
with:
|
||||
name: librechat
|
||||
repository: ${{ github.actor }}/librechat-chart
|
||||
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
|
||||
path: helm/librechat
|
||||
registry: ghcr.io
|
||||
registry_username: ${{ github.actor }}
|
||||
registry_password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# this is for the librechat-rag-api chart
|
||||
- name: Release Helm OCI Charts for librechat-rag-api
|
||||
uses: appany/helm-oci-chart-releaser@v0.4.2
|
||||
with:
|
||||
name: librechat-rag-api
|
||||
repository: ${{ github.actor }}/librechat-chart
|
||||
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
|
||||
path: helm/librechat-rag-api
|
||||
registry: ghcr.io
|
||||
registry_username: ${{ github.actor }}
|
||||
registry_password: ${{ secrets.GITHUB_TOKEN }}
|
||||
3
.gitignore
vendored
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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.8.0-rc1
|
||||
# v0.8.0-rc2
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile.multi
|
||||
# v0.8.0-rc1
|
||||
# v0.8.0-rc2
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
|
||||
@@ -1222,7 +1222,9 @@ ${convo}
|
||||
}
|
||||
|
||||
if (this.isOmni === true && modelOptions.max_tokens != null) {
|
||||
modelOptions.max_completion_tokens = modelOptions.max_tokens;
|
||||
const paramName =
|
||||
modelOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
|
||||
modelOptions[paramName] = modelOptions.max_tokens;
|
||||
delete modelOptions.max_tokens;
|
||||
}
|
||||
if (this.isOmni === true && modelOptions.temperature != null) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/openai": "^0.5.18",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.69",
|
||||
"@librechat/agents": "^2.4.75",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
const { logger } = require('~/config');
|
||||
|
||||
//handle duplicates
|
||||
const handleDuplicateKeyError = (err, res) => {
|
||||
logger.error('Duplicate key error:', err.keyValue);
|
||||
const field = `${JSON.stringify(Object.keys(err.keyValue))}`;
|
||||
const code = 409;
|
||||
res
|
||||
.status(code)
|
||||
.send({ messages: `An document with that ${field} already exists.`, fields: field });
|
||||
};
|
||||
|
||||
//handle validation errors
|
||||
const handleValidationError = (err, res) => {
|
||||
logger.error('Validation error:', err.errors);
|
||||
let errors = Object.values(err.errors).map((el) => el.message);
|
||||
let fields = `${JSON.stringify(Object.values(err.errors).map((el) => el.path))}`;
|
||||
let code = 400;
|
||||
if (errors.length > 1) {
|
||||
errors = errors.join(' ');
|
||||
res.status(code).send({ messages: `${JSON.stringify(errors)}`, fields: fields });
|
||||
} else {
|
||||
res.status(code).send({ messages: `${JSON.stringify(errors)}`, fields: fields });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = (err, _req, res, _next) => {
|
||||
try {
|
||||
if (err.name === 'ValidationError') {
|
||||
return handleValidationError(err, res);
|
||||
}
|
||||
if (err.code && err.code == 11000) {
|
||||
return handleDuplicateKeyError(err, res);
|
||||
}
|
||||
// Special handling for errors like SyntaxError
|
||||
if (err.statusCode && err.body) {
|
||||
return res.status(err.statusCode).send(err.body);
|
||||
}
|
||||
|
||||
logger.error('ErrorController => error', err);
|
||||
return res.status(500).send('An unknown error occurred.');
|
||||
} catch (err) {
|
||||
logger.error('ErrorController => processing error', err);
|
||||
return res.status(500).send('Processing error in ErrorController.');
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* @param {ServerRequest} req
|
||||
* @returns {Promise<TModelsConfig>} The models config.
|
||||
*/
|
||||
const getModelsConfig = async (req) => {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,10 +99,36 @@ const confirm2FA = async (req, res) => {
|
||||
|
||||
/**
|
||||
* Disable 2FA by clearing the stored secret and backup codes.
|
||||
* Requires verification with either TOTP token or backup code if 2FA is fully enabled.
|
||||
*/
|
||||
const disable2FA = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token, backupCode } = req.body;
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user || !user.totpSecret) {
|
||||
return res.status(400).json({ message: '2FA is not setup for this user' });
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
const secret = await getTOTPSecret(user.totpSecret);
|
||||
let isVerified = false;
|
||||
|
||||
if (token) {
|
||||
isVerified = await verifyTOTP(secret, token);
|
||||
} else if (backupCode) {
|
||||
isVerified = await verifyBackupCode({ user, backupCode });
|
||||
} else {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: 'Either token or backup code is required to disable 2FA' });
|
||||
}
|
||||
|
||||
if (!isVerified) {
|
||||
return res.status(401).json({ message: 'Invalid token or backup code' });
|
||||
}
|
||||
}
|
||||
await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false });
|
||||
return res.status(200).json();
|
||||
} catch (err) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,14 +8,12 @@ const express = require('express');
|
||||
const passport = require('passport');
|
||||
const compression = require('compression');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const { isEnabled, ErrorController } = require('@librechat/api');
|
||||
const { connectDb, indexSync } = require('~/db');
|
||||
|
||||
const validateImageRequest = require('./middleware/validateImageRequest');
|
||||
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
||||
const errorController = require('./controllers/ErrorController');
|
||||
const initializeMCPs = require('./services/initializeMCPs');
|
||||
const configureSocialLogins = require('./socialLogins');
|
||||
const AppService = require('./services/AppService');
|
||||
@@ -120,8 +118,7 @@ const startServer = async () => {
|
||||
app.use('/api/tags', routes.tags);
|
||||
app.use('/api/mcp', routes.mcp);
|
||||
|
||||
// Add the error controller one more time after all routes
|
||||
app.use(errorController);
|
||||
app.use(ErrorController);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.set({
|
||||
|
||||
@@ -92,7 +92,7 @@ async function healthCheckPoll(app, retries = 0) {
|
||||
if (response.status === 200) {
|
||||
return; // App is healthy
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore connection errors during polling
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const validateModel = async (req, res, next) => {
|
||||
return next();
|
||||
}
|
||||
|
||||
const { ILLEGAL_MODEL_REQ_SCORE: score = 5 } = process.env ?? {};
|
||||
const { ILLEGAL_MODEL_REQ_SCORE: score = 1 } = process.env ?? {};
|
||||
|
||||
const type = ViolationTypes.ILLEGAL_MODEL_REQUEST;
|
||||
const errorMessage = {
|
||||
|
||||
@@ -13,6 +13,8 @@ const { getRoleByName } = require('~/models/Role');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const memoryPayloadLimit = express.json({ limit: '100kb' });
|
||||
|
||||
const checkMemoryRead = generateCheckAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permissions: [Permissions.USE, Permissions.READ],
|
||||
@@ -60,6 +62,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
|
||||
|
||||
const memoryConfig = req.app.locals?.memory;
|
||||
const tokenLimit = memoryConfig?.tokenLimit;
|
||||
const charLimit = memoryConfig?.charLimit || 10000;
|
||||
|
||||
let usagePercentage = null;
|
||||
if (tokenLimit && tokenLimit > 0) {
|
||||
@@ -70,6 +73,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
|
||||
memories: sortedMemories,
|
||||
totalTokens,
|
||||
tokenLimit: tokenLimit || null,
|
||||
charLimit,
|
||||
usagePercentage,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -83,7 +87,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
|
||||
* Body: { key: string, value: string }
|
||||
* Returns 201 and { created: true, memory: <createdDoc> } when successful.
|
||||
*/
|
||||
router.post('/', checkMemoryCreate, async (req, res) => {
|
||||
router.post('/', memoryPayloadLimit, checkMemoryCreate, async (req, res) => {
|
||||
const { key, value } = req.body;
|
||||
|
||||
if (typeof key !== 'string' || key.trim() === '') {
|
||||
@@ -94,13 +98,25 @@ router.post('/', checkMemoryCreate, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
|
||||
}
|
||||
|
||||
const memoryConfig = req.app.locals?.memory;
|
||||
const charLimit = memoryConfig?.charLimit || 10000;
|
||||
|
||||
if (key.length > 1000) {
|
||||
return res.status(400).json({
|
||||
error: `Key exceeds maximum length of 1000 characters. Current length: ${key.length} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (value.length > charLimit) {
|
||||
return res.status(400).json({
|
||||
error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
|
||||
|
||||
const memories = await getAllUserMemories(req.user.id);
|
||||
|
||||
// Check token limit
|
||||
const memoryConfig = req.app.locals?.memory;
|
||||
const tokenLimit = memoryConfig?.tokenLimit;
|
||||
|
||||
if (tokenLimit) {
|
||||
@@ -175,7 +191,7 @@ router.patch('/preferences', checkMemoryOptOut, async (req, res) => {
|
||||
* Body: { key?: string, value: string }
|
||||
* Returns 200 and { updated: true, memory: <updatedDoc> } when successful.
|
||||
*/
|
||||
router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, async (req, res) => {
|
||||
const { key: urlKey } = req.params;
|
||||
const { key: bodyKey, value } = req.body || {};
|
||||
|
||||
@@ -183,9 +199,23 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
|
||||
}
|
||||
|
||||
// Use the key from the body if provided, otherwise use the key from the URL
|
||||
const newKey = bodyKey || urlKey;
|
||||
|
||||
const memoryConfig = req.app.locals?.memory;
|
||||
const charLimit = memoryConfig?.charLimit || 10000;
|
||||
|
||||
if (newKey.length > 1000) {
|
||||
return res.status(400).json({
|
||||
error: `Key exceeds maximum length of 1000 characters. Current length: ${newKey.length} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (value.length > charLimit) {
|
||||
return res.status(400).json({
|
||||
error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
|
||||
|
||||
@@ -196,7 +226,6 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Memory not found.' });
|
||||
}
|
||||
|
||||
// If the key is changing, we need to handle it specially
|
||||
if (newKey !== urlKey) {
|
||||
const keyExists = memories.find((m) => m.key === newKey);
|
||||
if (keyExists) {
|
||||
@@ -219,7 +248,6 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
return res.status(500).json({ error: 'Failed to delete old memory.' });
|
||||
}
|
||||
} else {
|
||||
// Key is not changing, just update the value
|
||||
const result = await setMemory({
|
||||
userId: req.user.id,
|
||||
key: newKey,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { randomState } = require('openid-client');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const {
|
||||
checkBan,
|
||||
logHeaders,
|
||||
@@ -10,8 +13,6 @@ const {
|
||||
checkDomainAllowed,
|
||||
} = require('~/server/middleware');
|
||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -46,13 +47,13 @@ const oauthHandler = async (req, res) => {
|
||||
};
|
||||
|
||||
router.get('/error', (req, res) => {
|
||||
// A single error message is pushed by passport when authentication fails.
|
||||
/** A single error message is pushed by passport when authentication fails. */
|
||||
const errorMessage = req.session?.messages?.pop() || 'Unknown error';
|
||||
logger.error('Error in OAuth authentication:', {
|
||||
message: req.session?.messages?.pop() || 'Unknown error',
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
|
||||
res.redirect(`${domains.client}/login?redirect=false`);
|
||||
res.redirect(`${domains.client}/login?redirect=false&error=${ErrorTypes.AUTH_FAILED}`);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
const { agentsConfigSetup, loadWebSearchConfig } = require('@librechat/api');
|
||||
const { loadMemoryConfig, agentsConfigSetup, loadWebSearchConfig } = require('@librechat/api');
|
||||
const {
|
||||
FileSources,
|
||||
loadOCRConfig,
|
||||
EModelEndpoint,
|
||||
loadMemoryConfig,
|
||||
getConfigDefaults,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
|
||||
@@ -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```/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { useAzurePlugins } = require('~/server/services/Config/EndpointService').config;
|
||||
const {
|
||||
getAnthropicModels,
|
||||
getBedrockModels,
|
||||
getOpenAIModels,
|
||||
getGoogleModels,
|
||||
getBedrockModels,
|
||||
getAnthropicModels,
|
||||
} = require('~/server/services/ModelService');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Loads the default models for the application.
|
||||
@@ -16,58 +15,42 @@ const { logger } = require('~/config');
|
||||
*/
|
||||
async function loadDefaultModels(req) {
|
||||
try {
|
||||
const [
|
||||
openAI,
|
||||
anthropic,
|
||||
azureOpenAI,
|
||||
gptPlugins,
|
||||
assistants,
|
||||
azureAssistants,
|
||||
google,
|
||||
bedrock,
|
||||
] = await Promise.all([
|
||||
getOpenAIModels({ user: req.user.id }).catch((error) => {
|
||||
logger.error('Error fetching OpenAI models:', error);
|
||||
return [];
|
||||
}),
|
||||
getAnthropicModels({ user: req.user.id }).catch((error) => {
|
||||
logger.error('Error fetching Anthropic models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => {
|
||||
logger.error('Error fetching Azure OpenAI models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ user: req.user.id, azure: useAzurePlugins, plugins: true }).catch(
|
||||
(error) => {
|
||||
logger.error('Error fetching Plugin models:', error);
|
||||
const [openAI, anthropic, azureOpenAI, assistants, azureAssistants, google, bedrock] =
|
||||
await Promise.all([
|
||||
getOpenAIModels({ user: req.user.id }).catch((error) => {
|
||||
logger.error('Error fetching OpenAI models:', error);
|
||||
return [];
|
||||
},
|
||||
),
|
||||
getOpenAIModels({ assistants: true }).catch((error) => {
|
||||
logger.error('Error fetching OpenAI Assistants API models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ azureAssistants: true }).catch((error) => {
|
||||
logger.error('Error fetching Azure OpenAI Assistants API models:', error);
|
||||
return [];
|
||||
}),
|
||||
Promise.resolve(getGoogleModels()).catch((error) => {
|
||||
logger.error('Error getting Google models:', error);
|
||||
return [];
|
||||
}),
|
||||
Promise.resolve(getBedrockModels()).catch((error) => {
|
||||
logger.error('Error getting Bedrock models:', error);
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
getAnthropicModels({ user: req.user.id }).catch((error) => {
|
||||
logger.error('Error fetching Anthropic models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => {
|
||||
logger.error('Error fetching Azure OpenAI models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ assistants: true }).catch((error) => {
|
||||
logger.error('Error fetching OpenAI Assistants API models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ azureAssistants: true }).catch((error) => {
|
||||
logger.error('Error fetching Azure OpenAI Assistants API models:', error);
|
||||
return [];
|
||||
}),
|
||||
Promise.resolve(getGoogleModels()).catch((error) => {
|
||||
logger.error('Error getting Google models:', error);
|
||||
return [];
|
||||
}),
|
||||
Promise.resolve(getBedrockModels()).catch((error) => {
|
||||
logger.error('Error getting Bedrock models:', error);
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
[EModelEndpoint.openAI]: openAI,
|
||||
[EModelEndpoint.agents]: openAI,
|
||||
[EModelEndpoint.google]: google,
|
||||
[EModelEndpoint.anthropic]: anthropic,
|
||||
[EModelEndpoint.gptPlugins]: gptPlugins,
|
||||
[EModelEndpoint.azureOpenAI]: azureOpenAI,
|
||||
[EModelEndpoint.assistants]: assistants,
|
||||
[EModelEndpoint.azureAssistants]: azureAssistants,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-data-provider');
|
||||
const { loadAgent } = require('~/models/Agent');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
||||
const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { validateAgentModel } = require('@librechat/api');
|
||||
const { createContentAggregator } = require('@librechat/agents');
|
||||
const {
|
||||
Constants,
|
||||
@@ -11,10 +12,12 @@ const {
|
||||
getDefaultHandlers,
|
||||
} = require('~/server/controllers/agents/callbacks');
|
||||
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
||||
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
||||
const { getCustomEndpointConfig } = require('~/server/services/Config');
|
||||
const { loadAgentTools } = require('~/server/services/ToolService');
|
||||
const AgentClient = require('~/server/controllers/agents/client');
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
const { logViolation } = require('~/cache');
|
||||
|
||||
function createToolLoader() {
|
||||
/**
|
||||
@@ -72,6 +75,19 @@ const initializeClient = async ({ req, res, endpointOption }) => {
|
||||
throw new Error('Agent not found');
|
||||
}
|
||||
|
||||
const modelsConfig = await getModelsConfig(req);
|
||||
const validationResult = await validateAgentModel({
|
||||
req,
|
||||
res,
|
||||
modelsConfig,
|
||||
logViolation,
|
||||
agent: primaryAgent,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(validationResult.error?.message);
|
||||
}
|
||||
|
||||
const agentConfigs = new Map();
|
||||
/** @type {Set<string>} */
|
||||
const allowedProviders = new Set(req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders);
|
||||
@@ -101,6 +117,19 @@ const initializeClient = async ({ req, res, endpointOption }) => {
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const validationResult = await validateAgentModel({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
modelsConfig,
|
||||
logViolation,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(validationResult.error?.message);
|
||||
}
|
||||
|
||||
const config = await initializeAgent({
|
||||
req,
|
||||
res,
|
||||
|
||||
@@ -33,7 +33,7 @@ async function withTimeout(promise, timeoutMs, timeoutMessage) {
|
||||
* @param {string} [params.body.model] - Optional. The ID of the model to be used for this run.
|
||||
* @param {string} [params.body.instructions] - Optional. Override the default system message of the assistant.
|
||||
* @param {string} [params.body.additional_instructions] - Optional. Appends additional instructions
|
||||
* at theend of the instructions for the run. This is useful for modifying
|
||||
* at the end of the instructions for the run. This is useful for modifying
|
||||
* the behavior on a per-run basis without overriding other instructions.
|
||||
* @param {Object[]} [params.body.tools] - Optional. Override the tools the assistant can use for this run.
|
||||
* @param {string[]} [params.body.file_ids] - Optional.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ const {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
isMemoryEnabled,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isMemoryEnabled } = require('@librechat/api');
|
||||
const { updateAccessPermissions } = require('~/models/Role');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Loads the default interface object.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const fs = require('fs');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const LdapStrategy = require('passport-ldapauth');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
|
||||
const { createUser, findUser, updateUser, countUsers } = require('~/models');
|
||||
const { getBalanceConfig } = require('~/server/services/Config');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
|
||||
const {
|
||||
LDAP_URL,
|
||||
@@ -90,6 +90,14 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
|
||||
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
|
||||
|
||||
let user = await findUser({ ldapId });
|
||||
if (user && user.provider !== 'ldap') {
|
||||
logger.info(
|
||||
`[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
|
||||
const fullName =
|
||||
|
||||
@@ -3,9 +3,9 @@ const fetch = require('node-fetch');
|
||||
const passport = require('passport');
|
||||
const client = require('openid-client');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { hashToken, logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, ErrorTypes } = require('librechat-data-provider');
|
||||
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
|
||||
const { isEnabled, safeStringify, logHeaders } = require('@librechat/api');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
@@ -320,6 +320,14 @@ async function setupOpenId() {
|
||||
} for openidId: ${claims.sub}`,
|
||||
);
|
||||
}
|
||||
if (user != null && user.provider !== 'openid') {
|
||||
logger.info(
|
||||
`[openidStrategy] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
const userinfo = {
|
||||
...claims,
|
||||
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const fetch = require('node-fetch');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { setupOpenId } = require('./openidStrategy');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { findUser, createUser, updateUser } = require('~/models');
|
||||
const { setupOpenId } = require('./openidStrategy');
|
||||
|
||||
// --- Mocks ---
|
||||
jest.mock('node-fetch');
|
||||
@@ -50,7 +51,7 @@ jest.mock('openid-client', () => {
|
||||
issuer: 'https://fake-issuer.com',
|
||||
// Add any other properties needed by the implementation
|
||||
}),
|
||||
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
|
||||
fetchUserInfo: jest.fn().mockImplementation(() => {
|
||||
// Only return additional properties, but don't override any claims
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
@@ -261,17 +262,20 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should update an existing user on login', async () => {
|
||||
// Arrange – simulate that a user already exists
|
||||
// Arrange – simulate that a user already exists with openid provider
|
||||
const existingUser = {
|
||||
_id: 'existingUserId',
|
||||
provider: 'local',
|
||||
provider: 'openid',
|
||||
email: tokenset.claims().email,
|
||||
openidId: '',
|
||||
username: '',
|
||||
name: '',
|
||||
};
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||
if (
|
||||
query.openidId === tokenset.claims().sub ||
|
||||
(query.email === tokenset.claims().email && query.provider === 'openid')
|
||||
) {
|
||||
return existingUser;
|
||||
}
|
||||
return null;
|
||||
@@ -294,12 +298,38 @@ describe('setupOpenId', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should block login when email exists with different provider', async () => {
|
||||
// Arrange – simulate that a user exists with same email but different provider
|
||||
const existingUser = {
|
||||
_id: 'existingUserId',
|
||||
provider: 'google',
|
||||
email: tokenset.claims().email,
|
||||
googleId: 'some-google-id',
|
||||
username: 'existinguser',
|
||||
name: 'Existing User',
|
||||
};
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.email === tokenset.claims().email && !query.provider) {
|
||||
return existingUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await validate(tokenset);
|
||||
|
||||
// Assert – verify that the strategy rejects login
|
||||
expect(result.user).toBe(false);
|
||||
expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED);
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should enforce the required role and reject login if missing', async () => {
|
||||
// Arrange – simulate a token without the required role.
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['SomeOtherRole'],
|
||||
});
|
||||
const userinfo = tokenset.claims();
|
||||
|
||||
// Act
|
||||
const { user, details } = await validate(tokenset);
|
||||
@@ -310,9 +340,6 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should attempt to download and save the avatar if picture is provided', async () => {
|
||||
// Arrange – ensure userinfo contains a picture URL
|
||||
const userinfo = tokenset.claims();
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
|
||||
@@ -22,9 +22,12 @@ const handleExistingUser = async (oldUser, avatarUrl) => {
|
||||
const isLocal = fileStrategy === FileSources.local;
|
||||
|
||||
let updatedAvatar = false;
|
||||
if (isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
|
||||
const hasManualFlag =
|
||||
typeof oldUser?.avatar === 'string' && oldUser.avatar.includes('?manual=true');
|
||||
|
||||
if (isLocal && (!oldUser?.avatar || !hasManualFlag)) {
|
||||
updatedAvatar = avatarUrl;
|
||||
} else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
|
||||
} else if (!isLocal && (!oldUser?.avatar || !hasManualFlag)) {
|
||||
const userId = oldUser._id;
|
||||
const resizedBuffer = await resizeAvatar({
|
||||
userId,
|
||||
|
||||
164
api/strategies/process.test.js
Normal file
164
api/strategies/process.test.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const { FileSources } = require('librechat-data-provider');
|
||||
const { handleExistingUser } = require('./process');
|
||||
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/images/avatar', () => ({
|
||||
resizeAvatar: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
updateUser: jest.fn(),
|
||||
createUser: jest.fn(),
|
||||
getUserById: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getBalanceConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
const { updateUser } = require('~/models');
|
||||
|
||||
describe('handleExistingUser', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.CDN_PROVIDER = FileSources.local;
|
||||
});
|
||||
|
||||
it('should handle null avatar without throwing error', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: null,
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should handle undefined avatar without throwing error', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
// avatar is undefined
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should not update avatar if it has manual=true flag', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://example.com/avatar.png?manual=true',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update avatar for local storage when avatar has no manual flag', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://example.com/old-avatar.png',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should process avatar for non-local storage', async () => {
|
||||
process.env.CDN_PROVIDER = 's3';
|
||||
|
||||
const mockProcessAvatar = jest.fn().mockResolvedValue('processed-avatar-url');
|
||||
getStrategyFunctions.mockReturnValue({ processAvatar: mockProcessAvatar });
|
||||
resizeAvatar.mockResolvedValue(Buffer.from('resized-image'));
|
||||
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: null,
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(resizeAvatar).toHaveBeenCalledWith({
|
||||
userId: 'user123',
|
||||
input: avatarUrl,
|
||||
});
|
||||
expect(mockProcessAvatar).toHaveBeenCalledWith({
|
||||
buffer: Buffer.from('resized-image'),
|
||||
userId: 'user123',
|
||||
manual: 'false',
|
||||
});
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: 'processed-avatar-url' });
|
||||
});
|
||||
|
||||
it('should not update if avatar already has manual flag in non-local storage', async () => {
|
||||
process.env.CDN_PROVIDER = 's3';
|
||||
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://cdn.example.com/avatar.png?manual=true',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(resizeAvatar).not.toHaveBeenCalled();
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle avatar with query parameters but without manual flag', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://example.com/avatar.png?size=large&format=webp',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should handle empty string avatar', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: '',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should handle avatar with manual=false parameter', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://example.com/avatar.png?manual=false',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should handle oldUser being null gracefully', async () => {
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
// This should throw an error when trying to access oldUser._id
|
||||
await expect(handleExistingUser(null, avatarUrl)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fetch = require('node-fetch');
|
||||
const passport = require('passport');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { hashToken, logger } = require('@librechat/data-schemas');
|
||||
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
@@ -203,6 +204,15 @@ async function setupSaml() {
|
||||
);
|
||||
}
|
||||
|
||||
if (user && user.provider !== 'saml') {
|
||||
logger.info(
|
||||
`[samlStrategy] User ${user.email} already exists with provider ${user.provider}`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const fullName = getFullName(profile);
|
||||
|
||||
const username = convertToUsername(
|
||||
|
||||
@@ -378,11 +378,11 @@ u7wlOSk+oFzDIO/UILIA
|
||||
});
|
||||
|
||||
it('should update an existing user on login', async () => {
|
||||
// Set up findUser to return an existing user
|
||||
// Set up findUser to return an existing user with saml provider
|
||||
const { findUser } = require('~/models');
|
||||
const existingUser = {
|
||||
_id: 'existing-user-id',
|
||||
provider: 'local',
|
||||
provider: 'saml',
|
||||
email: baseProfile.email,
|
||||
samlId: '',
|
||||
username: 'oldusername',
|
||||
@@ -400,6 +400,26 @@ u7wlOSk+oFzDIO/UILIA
|
||||
expect(user.email).toBe(baseProfile.email);
|
||||
});
|
||||
|
||||
it('should block login when email exists with different provider', async () => {
|
||||
// Set up findUser to return a user with different provider
|
||||
const { findUser } = require('~/models');
|
||||
const existingUser = {
|
||||
_id: 'existing-user-id',
|
||||
provider: 'google',
|
||||
email: baseProfile.email,
|
||||
googleId: 'some-google-id',
|
||||
username: 'existinguser',
|
||||
name: 'Existing User',
|
||||
};
|
||||
findUser.mockResolvedValue(existingUser);
|
||||
|
||||
const profile = { ...baseProfile };
|
||||
const result = await validate(profile);
|
||||
|
||||
expect(result.user).toBe(false);
|
||||
expect(result.details.message).toBe(require('librechat-data-provider').ErrorTypes.AUTH_FAILED);
|
||||
});
|
||||
|
||||
it('should attempt to download and save the avatar if picture is provided', async () => {
|
||||
const profile = { ...baseProfile };
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { createSocialUser, handleExistingUser } = require('./process');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { findUser } = require('~/models');
|
||||
|
||||
const socialLogin =
|
||||
@@ -11,12 +12,20 @@ const socialLogin =
|
||||
profile,
|
||||
});
|
||||
|
||||
const oldUser = await findUser({ email: email.trim() });
|
||||
const existingUser = await findUser({ email: email.trim() });
|
||||
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
|
||||
|
||||
if (oldUser) {
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
return cb(null, oldUser);
|
||||
if (existingUser?.provider === provider) {
|
||||
await handleExistingUser(existingUser, avatarUrl);
|
||||
return cb(null, existingUser);
|
||||
} else if (existingUser) {
|
||||
logger.info(
|
||||
`[${provider}Login] User ${email} already exists with provider ${existingUser.provider}`,
|
||||
);
|
||||
const error = new Error(ErrorTypes.AUTH_FAILED);
|
||||
error.code = ErrorTypes.AUTH_FAILED;
|
||||
error.provider = existingUser.provider;
|
||||
return cb(error);
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
|
||||
@@ -1370,7 +1370,7 @@
|
||||
* @property {string} [model] - The model that the assistant used for this run.
|
||||
* @property {string} [instructions] - The instructions that the assistant used for this run.
|
||||
* @property {string} [additional_instructions] - Optional. Appends additional instructions
|
||||
* at theend of the instructions for the run. This is useful for modifying
|
||||
* at the end of the instructions for the run. This is useful for modifying
|
||||
* @property {Tool[]} [tools] - The list of tools used for this run.
|
||||
* @property {string[]} [file_ids] - The list of File IDs used for this run.
|
||||
* @property {Object} [metadata] - Metadata associated with this run.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
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>}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { OpenIDIcon } from '@librechat/client';
|
||||
import { ErrorTypes } from 'librechat-data-provider';
|
||||
import { OpenIDIcon, useToastContext } from '@librechat/client';
|
||||
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
|
||||
import SocialButton from '~/components/Auth/SocialButton';
|
||||
@@ -11,6 +12,7 @@ import LoginForm from './LoginForm';
|
||||
|
||||
function Login() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { error, setError, login } = useAuthContext();
|
||||
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
@@ -21,6 +23,19 @@ function Login() {
|
||||
// Persist the disable flag locally so that once detected, auto-redirect stays disabled.
|
||||
const [isAutoRedirectDisabled, setIsAutoRedirectDisabled] = useState(disableAutoRedirect);
|
||||
|
||||
useEffect(() => {
|
||||
const oauthError = searchParams?.get('error');
|
||||
if (oauthError && oauthError === ErrorTypes.AUTH_FAILED) {
|
||||
showToast({
|
||||
message: localize('com_auth_error_oauth_failed'),
|
||||
status: 'error',
|
||||
});
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('error');
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}, [searchParams, setSearchParams, showToast, localize]);
|
||||
|
||||
// Once the disable flag is detected, update local state and remove the parameter from the URL.
|
||||
useEffect(() => {
|
||||
if (disableAutoRedirect) {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets
|
||||
import { ViolationTypes, ErrorTypes, alternateName } from 'librechat-data-provider';
|
||||
import type { TOpenAIMessage } from 'librechat-data-provider';
|
||||
import type { LocalizeFunction } from '~/common';
|
||||
import { formatJSON, extractJson, isJson } from '~/utils/json';
|
||||
import { useLocalize } from '~/hooks';
|
||||
@@ -25,7 +24,7 @@ type TTokenBalance = {
|
||||
prev_count: number;
|
||||
violation_count: number;
|
||||
date: Date;
|
||||
generations?: TOpenAIMessage[];
|
||||
generations?: unknown[];
|
||||
};
|
||||
|
||||
type TExpiredKey = {
|
||||
@@ -44,6 +43,17 @@ const errorMessages = {
|
||||
[ErrorTypes.NO_BASE_URL]: 'com_error_no_base_url',
|
||||
[ErrorTypes.INVALID_ACTION]: `com_error_${ErrorTypes.INVALID_ACTION}`,
|
||||
[ErrorTypes.INVALID_REQUEST]: `com_error_${ErrorTypes.INVALID_REQUEST}`,
|
||||
[ErrorTypes.MISSING_MODEL]: (json: TGenericError, localize: LocalizeFunction) => {
|
||||
const { info: endpoint } = json;
|
||||
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
||||
return localize('com_error_missing_model', { 0: provider });
|
||||
},
|
||||
[ErrorTypes.MODELS_NOT_LOADED]: 'com_error_models_not_loaded',
|
||||
[ErrorTypes.ENDPOINT_MODELS_NOT_LOADED]: (json: TGenericError, localize: LocalizeFunction) => {
|
||||
const { info: endpoint } = json;
|
||||
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
||||
return localize('com_error_endpoint_models_not_loaded', { 0: provider });
|
||||
},
|
||||
[ErrorTypes.NO_SYSTEM_MESSAGES]: `com_error_${ErrorTypes.NO_SYSTEM_MESSAGES}`,
|
||||
[ErrorTypes.EXPIRED_USER_KEY]: (json: TExpiredKey, localize: LocalizeFunction) => {
|
||||
const { expiredAt, endpoint } = json;
|
||||
@@ -65,6 +75,12 @@ const errorMessages = {
|
||||
[ErrorTypes.GOOGLE_TOOL_CONFLICT]: 'com_error_google_tool_conflict',
|
||||
[ViolationTypes.BAN]:
|
||||
'Your account has been temporarily banned due to violations of our service.',
|
||||
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: (json: TGenericError, localize: LocalizeFunction) => {
|
||||
const { info } = json;
|
||||
const [endpoint, model = 'unknown'] = info?.split('|') ?? [];
|
||||
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
||||
return localize('com_error_illegal_model_request', { 0: model, 1: provider });
|
||||
},
|
||||
invalid_api_key:
|
||||
'Invalid API key. Please check your API key and try again. You can do this by clicking on the model logo in the left corner of the textbox and selecting "Set Token" for the current selected endpoint. Thank you for your understanding.',
|
||||
insufficient_quota:
|
||||
|
||||
@@ -7,7 +7,7 @@ import BackupCodesItem from './BackupCodesItem';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
|
||||
function Account() {
|
||||
const user = useAuthContext();
|
||||
const { user } = useAuthContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
@@ -17,12 +17,12 @@ function Account() {
|
||||
<div className="pb-3">
|
||||
<Avatar />
|
||||
</div>
|
||||
{user?.user?.provider === 'local' && (
|
||||
{user?.provider === 'local' && (
|
||||
<>
|
||||
<div className="pb-3">
|
||||
<EnableTwoFactorItem />
|
||||
</div>
|
||||
{user?.user?.twoFactorEnabled && (
|
||||
{user?.twoFactorEnabled && (
|
||||
<div className="pb-3">
|
||||
<BackupCodesItem />
|
||||
</div>
|
||||
|
||||
@@ -39,8 +39,8 @@ const TwoFactorAuthentication: React.FC = () => {
|
||||
const [secret, setSecret] = useState<string>('');
|
||||
const [otpauthUrl, setOtpauthUrl] = useState<string>('');
|
||||
const [downloaded, setDownloaded] = useState<boolean>(false);
|
||||
const [disableToken, setDisableToken] = useState<string>('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [_disableToken, setDisableToken] = useState<string>('');
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [verificationToken, setVerificationToken] = useState<string>('');
|
||||
const [phase, setPhase] = useState<Phase>(user?.twoFactorEnabled ? 'disable' : 'setup');
|
||||
@@ -166,32 +166,26 @@ const TwoFactorAuthentication: React.FC = () => {
|
||||
payload.token = token.trim();
|
||||
}
|
||||
|
||||
verify2FAMutate(payload, {
|
||||
disable2FAMutate(payload, {
|
||||
onSuccess: () => {
|
||||
disable2FAMutate(undefined, {
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_ui_2fa_disabled') });
|
||||
setDialogOpen(false);
|
||||
setUser(
|
||||
(prev) =>
|
||||
({
|
||||
...prev,
|
||||
totpSecret: '',
|
||||
backupCodes: [],
|
||||
twoFactorEnabled: false,
|
||||
}) as TUser,
|
||||
);
|
||||
setPhase('setup');
|
||||
setOtpauthUrl('');
|
||||
},
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
|
||||
});
|
||||
showToast({ message: localize('com_ui_2fa_disabled') });
|
||||
setDialogOpen(false);
|
||||
setUser(
|
||||
(prev) =>
|
||||
({
|
||||
...prev,
|
||||
totpSecret: '',
|
||||
backupCodes: [],
|
||||
twoFactorEnabled: false,
|
||||
}) as TUser,
|
||||
);
|
||||
setPhase('setup');
|
||||
setOtpauthUrl('');
|
||||
},
|
||||
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
|
||||
});
|
||||
},
|
||||
[verify2FAMutate, disable2FAMutate, showToast, localize, setUser],
|
||||
[disable2FAMutate, showToast, localize, setUser],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
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>([
|
||||
|
||||
@@ -134,12 +134,12 @@ export const useConfirmTwoFactorMutation = (): UseMutationResult<
|
||||
export const useDisableTwoFactorMutation = (): UseMutationResult<
|
||||
t.TDisable2FAResponse,
|
||||
unknown,
|
||||
void,
|
||||
t.TDisable2FARequest | undefined,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(() => dataService.disableTwoFactor(), {
|
||||
onSuccess: (data) => {
|
||||
return useMutation((payload?: t.TDisable2FARequest) => dataService.disableTwoFactor(payload), {
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData([QueryKeys.user, '2fa'], null);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"com_ui_copy_to_clipboard": "Copia al porta-retalls",
|
||||
"com_ui_create": "Crea",
|
||||
"com_ui_create_link": "Crea enllaç",
|
||||
"com_ui_create_memory": "Crear memòria",
|
||||
"com_ui_create_prompt": "Crea prompt",
|
||||
"com_ui_currently_production": "Actualment en producció",
|
||||
"com_ui_custom": "Personalitzat",
|
||||
@@ -622,6 +623,7 @@
|
||||
"com_ui_delete_confirm": "Això eliminarà",
|
||||
"com_ui_delete_confirm_prompt_version_var": "Això eliminarà la versió seleccionada per a \"{{0}}.\" Si no hi ha altres versions, s'eliminarà el prompt.",
|
||||
"com_ui_delete_conversation": "Vols eliminar el xat?",
|
||||
"com_ui_delete_memory": "Esborrar memòria",
|
||||
"com_ui_delete_prompt": "Vols eliminar el prompt?",
|
||||
"com_ui_delete_shared_link": "Vols eliminar l'enllaç compartit?",
|
||||
"com_ui_delete_tool": "Elimina eina",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"com_auth_error_login_rl": "Too many login attempts in a short amount of time. Please try again later.",
|
||||
"com_auth_error_login_server": "There was an internal server error. Please wait a few moments and try again.",
|
||||
"com_auth_error_login_unverified": "Your account has not been verified. Please check your email for a verification link.",
|
||||
"com_auth_error_oauth_failed": "Authentication failed. Please check your login method and try again.",
|
||||
"com_auth_facebook_login": "Continue with Facebook",
|
||||
"com_auth_full_name": "Full name",
|
||||
"com_auth_github_login": "Continue with Github",
|
||||
@@ -229,7 +230,7 @@
|
||||
"com_endpoint_openai_max_tokens": "Optional 'max_tokens' field, representing the maximum number of tokens that can be generated in the chat completion. The total length of input tokens and generated tokens is limited by the models context length. You may experience errors if this number exceeds the max context tokens.",
|
||||
"com_endpoint_openai_pres": "Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.",
|
||||
"com_endpoint_openai_prompt_prefix_placeholder": "Set custom instructions to include in System Message. Default: none",
|
||||
"com_endpoint_openai_reasoning_effort": "o1 and o3 models only: constrains effort on reasoning for reasoning models. Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response.",
|
||||
"com_endpoint_openai_reasoning_effort": "Reasoning models only: constrains effort on reasoning. Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response. 'Minimal' produces very few reasoning tokens for fastest time-to-first-token, especially well-suited for coding and instruction following.",
|
||||
"com_endpoint_openai_reasoning_summary": "Responses API only: A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. Set to none,auto, concise, or detailed.",
|
||||
"com_endpoint_openai_resend": "Resend all previously attached images. Note: this can significantly increase token cost and you may experience errors with many image attachments.",
|
||||
"com_endpoint_openai_resend_files": "Resend all previously attached files. Note: this will increase token cost and you may experience errors with many attachments.",
|
||||
@@ -238,6 +239,7 @@
|
||||
"com_endpoint_openai_topp": "An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We recommend altering this or temperature but not both.",
|
||||
"com_endpoint_openai_use_responses_api": "Use the Responses API instead of Chat Completions, which includes extended features from OpenAI. Required for o1-pro, o3-pro, and to enable reasoning summaries.",
|
||||
"com_endpoint_openai_use_web_search": "Enable web search functionality using OpenAI's built-in search capabilities. This allows the model to search the web for up-to-date information and provide more accurate, current responses.",
|
||||
"com_endpoint_openai_verbosity": "Constrains the verbosity of the model's response. Lower values will result in more concise responses, while higher values will result in more verbose responses. Currently supported values are low, medium, and high.",
|
||||
"com_endpoint_output": "Output",
|
||||
"com_endpoint_plug_image_detail": "Image Detail",
|
||||
"com_endpoint_plug_resend_files": "Resend Files",
|
||||
@@ -286,6 +288,8 @@
|
||||
"com_endpoint_use_active_assistant": "Use Active Assistant",
|
||||
"com_endpoint_use_responses_api": "Use Responses API",
|
||||
"com_endpoint_use_search_grounding": "Grounding with Google Search",
|
||||
"com_endpoint_verbosity": "Verbosity",
|
||||
"com_error_endpoint_models_not_loaded": "Models for {{0}} could not be loaded. Please refresh the page and try again.",
|
||||
"com_error_expired_user_key": "Provided key for {{0}} expired at {{1}}. Please provide a new key and try again.",
|
||||
"com_error_files_dupe": "Duplicate file detected.",
|
||||
"com_error_files_empty": "Empty files are not allowed.",
|
||||
@@ -296,9 +300,12 @@
|
||||
"com_error_files_validation": "An error occurred while validating the file.",
|
||||
"com_error_google_tool_conflict": "Usage of built-in Google tools are not supported with external tools. Please disable either the built-in tools or the external tools.",
|
||||
"com_error_heic_conversion": "Failed to convert HEIC image to JPEG. Please try converting the image manually or use a different format.",
|
||||
"com_error_illegal_model_request": "The model \"{{0}}\" is not available for {{1}}. Please select a different model.",
|
||||
"com_error_input_length": "The latest message token count is too long, exceeding the token limit, or your token limit parameters are misconfigured, adversely affecting the context window. More info: {{0}}. Please shorten your message, adjust the max context size from the conversation parameters, or fork the conversation to continue.",
|
||||
"com_error_invalid_agent_provider": "The \"{{0}}\" provider is not available for use with Agents. Please go to your agent's settings and select a currently available provider.",
|
||||
"com_error_invalid_user_key": "Invalid key provided. Please provide a valid key and try again.",
|
||||
"com_error_missing_model": "No model selected for {{0}}. Please select a model and try again.",
|
||||
"com_error_models_not_loaded": "Models configuration could not be loaded. Please refresh the page and try again.",
|
||||
"com_error_moderation": "It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We're unable to proceed with this specific topic. If you have any other questions or topics you'd like to explore, please edit your message, or create a new conversation.",
|
||||
"com_error_no_base_url": "No base URL found. Please provide one and try again.",
|
||||
"com_error_no_user_key": "No key found. Please provide a key and try again.",
|
||||
@@ -448,7 +455,7 @@
|
||||
"com_nav_maximize_chat_space": "Maximize chat space",
|
||||
"com_nav_mcp_configure_server": "Configure {{0}}",
|
||||
"com_nav_mcp_status_connecting": "{{0}} - Connecting",
|
||||
"com_nav_mcp_vars_update_error": "Error updating MCP custom user variables: {{0}}",
|
||||
"com_nav_mcp_vars_update_error": "Error updating MCP custom user variables",
|
||||
"com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.",
|
||||
"com_nav_modular_chat": "Enable switching Endpoints mid-conversation",
|
||||
"com_nav_my_files": "My Files",
|
||||
@@ -554,7 +561,6 @@
|
||||
"com_ui_agent_var": "{{0}} agent",
|
||||
"com_ui_agent_version": "Version",
|
||||
"com_ui_agent_version_active": "Active Version",
|
||||
"com_ui_agent_version_duplicate": "Duplicate version detected. This would create a version identical to Version {{versionIndex}}.",
|
||||
"com_ui_agent_version_empty": "No versions available",
|
||||
"com_ui_agent_version_error": "Error fetching versions",
|
||||
"com_ui_agent_version_history": "Version History",
|
||||
@@ -882,6 +888,7 @@
|
||||
"com_ui_memory_would_exceed": "Cannot save - would exceed limit by {{tokens}} tokens. Delete existing memories to make space.",
|
||||
"com_ui_mention": "Mention an endpoint, assistant, or preset to quickly switch to it",
|
||||
"com_ui_min_tags": "Cannot remove more values, a minimum of {{0}} are required.",
|
||||
"com_ui_minimal": "Minimal",
|
||||
"com_ui_misc": "Misc.",
|
||||
"com_ui_model": "Model",
|
||||
"com_ui_model_parameters": "Model Parameters",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
"com_auth_submit_registration": "Lähetä rekisteröityminen",
|
||||
"com_auth_to_reset_your_password": "asettaaksesi uuden salasanan.",
|
||||
"com_auth_to_try_again": "kokeillaksesi uudestaan.",
|
||||
"com_auth_two_factor": "Katso kertakäyttöinen koodi tunnistautumissovelluksestasi",
|
||||
"com_auth_username": "Käyttäjänimi (valinnainen)",
|
||||
"com_auth_username_max_length": "Käyttäjänimi voi olla enintään 20 merkkiä pitkä",
|
||||
"com_auth_username_min_length": "Käyttäjänimessä on oltava vähintään 2 merkkiä",
|
||||
|
||||
@@ -547,7 +547,6 @@
|
||||
"com_ui_agent_var": "agent {{0}}",
|
||||
"com_ui_agent_version": "Version",
|
||||
"com_ui_agent_version_active": "Version active",
|
||||
"com_ui_agent_version_duplicate": "Duplicata de version détecté. Cela créerait une version identique à la version {{versionIndex}}",
|
||||
"com_ui_agent_version_empty": "Aucune version disponible",
|
||||
"com_ui_agent_version_error": "Erreur lors de la collecte des versions",
|
||||
"com_ui_agent_version_history": "Historique des versions",
|
||||
@@ -573,6 +572,7 @@
|
||||
"com_ui_archive_error": "échec de l'archivage de la conversation",
|
||||
"com_ui_artifact_click": "Cliquer pour ouvrir",
|
||||
"com_ui_artifacts": "Artefacts",
|
||||
"com_ui_artifacts_options": "Options des Artefacts",
|
||||
"com_ui_artifacts_toggle": "Afficher/Masquer l'interface des artefacts",
|
||||
"com_ui_artifacts_toggle_agent": "Activer Artifacts",
|
||||
"com_ui_ascending": "Croissant",
|
||||
@@ -772,6 +772,7 @@
|
||||
"com_ui_fork_change_default": "Option de fourche par défaut",
|
||||
"com_ui_fork_default": "Utiliser l'option de fourche par défaut",
|
||||
"com_ui_fork_error": "Une erreur s'est produite lors du dédoublement de la conversation",
|
||||
"com_ui_fork_error_rate_limit": "Trop de demandes de duplications. Merci de réessayer plus tard.",
|
||||
"com_ui_fork_from_message": "Sélectionner une option de bifurcation",
|
||||
"com_ui_fork_info_1": "Utilisez ce paramètre pour créer une bifurcation des messages avec le comportement souhaité.",
|
||||
"com_ui_fork_info_2": "\"Forker\" fait référence à la création d'une nouvelle conversation qui commence/se termine à partir de messages spécifiques dans la conversation actuelle, en créant une copie selon les options sélectionnées.",
|
||||
@@ -1065,6 +1066,7 @@
|
||||
"com_ui_web_search_scraper": "Extracteur (scraper)",
|
||||
"com_ui_web_search_scraper_firecrawl": "API de Firecrawl",
|
||||
"com_ui_web_search_scraper_firecrawl_key": "Obtenez votre clé API pour Firecrawl",
|
||||
"com_ui_web_search_searxng_api_key": "Entrez la clé d'API de SearXNG (facultatif)",
|
||||
"com_ui_web_search_searxng_instance_url": "Adresse URL de l'instance SearXNG",
|
||||
"com_ui_web_searching": "Rechercher sur le web",
|
||||
"com_ui_web_searching_again": "Rechercher à nouveau sur le web",
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"com_endpoint_top_k": "Top K",
|
||||
"com_endpoint_top_p": "Top P",
|
||||
"com_endpoint_use_active_assistant": "השתמש ב-סייען פעיל",
|
||||
"com_endpoint_use_responses_api": "השתמש ב-API של תגובות",
|
||||
"com_endpoint_use_search_grounding": "התבססות על חיפוש גוגל",
|
||||
"com_error_expired_user_key": "המפתח שסופק עבור {{0}} פג ב-{{1}}. אנא ספק מפתח חדש ונסה שוב.",
|
||||
"com_error_files_dupe": "זוהה קובץ כפול",
|
||||
@@ -540,7 +541,6 @@
|
||||
"com_ui_agent_var": "{{0}} סוכנים",
|
||||
"com_ui_agent_version": "גרסה",
|
||||
"com_ui_agent_version_active": "גרסת הפעלה",
|
||||
"com_ui_agent_version_duplicate": "זוהתה גרסה כפולה, פעולה זו תיצור גרסה זהה לגרסה {{versionIndex}}.",
|
||||
"com_ui_agent_version_empty": "אין גרסאות זמינות",
|
||||
"com_ui_agent_version_error": "שגיאה באחזור גרסאות",
|
||||
"com_ui_agent_version_history": "היסטוריית גרסאות",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"com_endpoint_deprecated": "非推奨",
|
||||
"com_endpoint_deprecated_info": "このエンドポイントは非推奨であり、将来のバージョンで削除される可能性があります。",
|
||||
"com_endpoint_deprecated_info_a11y": "プラグインエンドポイントは非推奨であり、将来のバージョンで削除される可能性があります。",
|
||||
"com_endpoint_disable_streaming": "ストリーミング応答を無効にし、完全な応答を一度に受信する。o3 のように、ストリーミングのための組織検証を必要とするモデルに便利です。",
|
||||
"com_endpoint_disable_streaming_label": "ストリーミングを無効にする",
|
||||
"com_endpoint_examples": " プリセット名",
|
||||
"com_endpoint_export": "エクスポート",
|
||||
"com_endpoint_export_share": "エクスポート/共有",
|
||||
@@ -227,7 +229,7 @@
|
||||
"com_endpoint_openai_max_tokens": "オプションの 'max_tokens' フィールドで、チャット補完時に生成可能な最大トークン数を設定します。入力トークンと生成されたトークンの合計長さは、モデルのコンテキスト長によって制限されています。この数値がコンテキストの最大トークン数を超えると、エラーが発生する可能性があります。",
|
||||
"com_endpoint_openai_pres": "-2.0から2.0の値。正の値は入力すると、新規トークンの出現に基づいたペナルティを課し、新しいトピックについて話す可能性を高める。",
|
||||
"com_endpoint_openai_prompt_prefix_placeholder": "システムメッセージに含める Custom Instructions。デフォルト: none",
|
||||
"com_endpoint_openai_reasoning_effort": "o1 モデルのみ: 推論モデルの推論の努力を制限します。推論の努力を減らすと、応答が速くなり、応答で推論に使用されるトークンが少なくなります。",
|
||||
"com_endpoint_openai_reasoning_effort": "推論モデルのみ:推論の努力を制限します。推論の努力を減らすことで、応答が速くなり、応答における推論に使用されるトークンが少なくなります。「最小限」は、特にコーディングや指示のフォローに適しており、最初のトークンまでの時間を最速にするためにごくわずかな推論トークンを生成します。",
|
||||
"com_endpoint_openai_reasoning_summary": "Responses APIのみ:モデルが実行した推論の概要。これは、モデルの推論プロセスのデバッグや理解に役立ちます。none、auto、concise、detailedのいずれかに設定してください。",
|
||||
"com_endpoint_openai_resend": "これまでに添付した画像を全て再送信します。注意:トークン数が大幅に増加したり、多くの画像を添付するとエラーが発生する可能性があります。",
|
||||
"com_endpoint_openai_resend_files": "以前に添付されたすべてのファイルを再送信します。注意:これにより、トークンのコストが増加し、多くの添付ファイルでエラーが発生する可能性があります。",
|
||||
@@ -236,6 +238,7 @@
|
||||
"com_endpoint_openai_topp": "nucleus sampling と呼ばれるtemperatureを使用したサンプリングの代わりに、top_p確率質量のトークンの結果を考慮します。つまり、0.1とすると確率質量の上位10%を構成するトークンのみが考慮されます。この値かtemperatureの変更をおすすめしますが、両方を変更はおすすめしません。",
|
||||
"com_endpoint_openai_use_responses_api": "Chat Completions の代わりに、OpenAI の拡張機能を含む Responses API を使用してください。o1-pro、o3-pro、および推論要約を有効にするために必要です。",
|
||||
"com_endpoint_openai_use_web_search": "OpenAIの組み込み検索機能を使用して、ウェブ検索機能を有効にします。これにより、モデルは最新の情報をウェブで検索し、より正確で最新の回答を提供できるようになります。",
|
||||
"com_endpoint_openai_verbosity": "モデルの応答の冗長性を制限します。値が低いほど簡潔な応答となり、値が高いほど冗長な応答となります。現在サポートされている値はlow、medium、highです。",
|
||||
"com_endpoint_output": "出力",
|
||||
"com_endpoint_plug_image_detail": "画像の詳細",
|
||||
"com_endpoint_plug_resend_files": "ファイルを再送",
|
||||
@@ -284,6 +287,7 @@
|
||||
"com_endpoint_use_active_assistant": "アクティブなアシスタントを使用",
|
||||
"com_endpoint_use_responses_api": "レスポンスAPIの使用",
|
||||
"com_endpoint_use_search_grounding": "Google検索でグラウンディング",
|
||||
"com_endpoint_verbosity": "冗長性",
|
||||
"com_error_expired_user_key": "{{0}}の提供されたキーは{{1}}で期限切れです。キーを入力して再試行してください。",
|
||||
"com_error_files_dupe": "重複したファイルが検出されました。",
|
||||
"com_error_files_empty": "空のファイルはアップロードできません",
|
||||
@@ -324,6 +328,7 @@
|
||||
"com_nav_auto_transcribe_audio": "オーディオを自動で書き起こす",
|
||||
"com_nav_automatic_playback": "最新メッセージを自動再生",
|
||||
"com_nav_balance": "バランス",
|
||||
"com_nav_balance_auto_refill_disabled": "自動補充は無効。",
|
||||
"com_nav_balance_auto_refill_error": "自動補充設定の読み込み中にエラーが発生しました。",
|
||||
"com_nav_balance_auto_refill_settings": "自動補充設定",
|
||||
"com_nav_balance_day": "日",
|
||||
@@ -432,8 +437,10 @@
|
||||
"com_nav_lang_spanish": "Español",
|
||||
"com_nav_lang_swedish": "Svenska",
|
||||
"com_nav_lang_thai": "ไทย",
|
||||
"com_nav_lang_tibetan": "བོད་ སྐད་",
|
||||
"com_nav_lang_traditional_chinese": "繁體中文",
|
||||
"com_nav_lang_turkish": "Türkçe",
|
||||
"com_nav_lang_ukrainian": "ウクラエンスカ",
|
||||
"com_nav_lang_uyghur": "ウラジオストク",
|
||||
"com_nav_lang_vietnamese": "Tiếng Việt",
|
||||
"com_nav_language": "言語",
|
||||
@@ -441,6 +448,8 @@
|
||||
"com_nav_log_out": "ログアウト",
|
||||
"com_nav_long_audio_warning": "長いテキストの処理には時間がかかります。",
|
||||
"com_nav_maximize_chat_space": "チャット画面を最大化",
|
||||
"com_nav_mcp_configure_server": "{{0}}を設定",
|
||||
"com_nav_mcp_status_connecting": "{{0}} - 接続中",
|
||||
"com_nav_mcp_vars_update_error": "MCP カスタムユーザ変数の更新エラー: {{0}}",
|
||||
"com_nav_mcp_vars_updated": "MCP カスタムユーザー変数が正常に更新されました。",
|
||||
"com_nav_modular_chat": "会話の途中でのエンドポイント切替を有効化",
|
||||
@@ -520,6 +529,7 @@
|
||||
"com_ui_2fa_verified": "2要素認証の認証に成功しました",
|
||||
"com_ui_accept": "同意します",
|
||||
"com_ui_action_button": "アクションボタン",
|
||||
"com_ui_active": "有効化",
|
||||
"com_ui_add": "追加",
|
||||
"com_ui_add_mcp": "MCPの追加",
|
||||
"com_ui_add_mcp_server": "MCPサーバーの追加",
|
||||
@@ -546,7 +556,6 @@
|
||||
"com_ui_agent_var": "{{0}}エージェント",
|
||||
"com_ui_agent_version": "バージョン",
|
||||
"com_ui_agent_version_active": "アクティブバージョン",
|
||||
"com_ui_agent_version_duplicate": "重複バージョンが検出されました。これにより、バージョン{{versionIndex}}と同一のバージョンが作成されます。",
|
||||
"com_ui_agent_version_empty": "利用可能なバージョンはありません",
|
||||
"com_ui_agent_version_error": "バージョン取得エラー",
|
||||
"com_ui_agent_version_history": "バージョン履歴",
|
||||
@@ -590,6 +599,7 @@
|
||||
"com_ui_attachment": "添付ファイル",
|
||||
"com_ui_auth_type": "認証タイプ",
|
||||
"com_ui_auth_url": "認証URL",
|
||||
"com_ui_authenticate": "認証",
|
||||
"com_ui_authentication": "認証",
|
||||
"com_ui_authentication_type": "認証タイプ",
|
||||
"com_ui_auto": "自動",
|
||||
@@ -647,8 +657,10 @@
|
||||
"com_ui_confirm_action": "実行する",
|
||||
"com_ui_confirm_admin_use_change": "この設定を変更すると、あなた自身を含む管理者のアクセスがブロックされます。本当によろしいですか?",
|
||||
"com_ui_confirm_change": "変更の確認",
|
||||
"com_ui_connecting": "接続中",
|
||||
"com_ui_context": "コンテキスト",
|
||||
"com_ui_continue": "続ける",
|
||||
"com_ui_continue_oauth": "OAuthで続行",
|
||||
"com_ui_controls": "管理",
|
||||
"com_ui_convo_delete_error": "会話の削除に失敗しました",
|
||||
"com_ui_copied": "コピーしました!",
|
||||
@@ -838,9 +850,16 @@
|
||||
"com_ui_low": "低い",
|
||||
"com_ui_manage": "管理",
|
||||
"com_ui_max_tags": "最新の値を使用した場合、許可される最大数は {{0}} です。",
|
||||
"com_ui_mcp_authenticated_success": "MCPサーバー{{0}}認証成功",
|
||||
"com_ui_mcp_enter_var": "{{0}}の値を入力する。",
|
||||
"com_ui_mcp_init_failed": "MCPサーバーの初期化に失敗しました",
|
||||
"com_ui_mcp_initialize": "初期化",
|
||||
"com_ui_mcp_initialized_success": "MCPサーバー{{0}}初期化に成功",
|
||||
"com_ui_mcp_oauth_cancelled": "OAuthログインがキャンセルされた {{0}}",
|
||||
"com_ui_mcp_oauth_timeout": "OAuthログインがタイムアウトしました。 {{0}}",
|
||||
"com_ui_mcp_server_not_found": "サーバーが見つかりません。",
|
||||
"com_ui_mcp_servers": "MCP サーバー",
|
||||
"com_ui_mcp_update_var": "{{0}}を更新",
|
||||
"com_ui_mcp_url": "MCPサーバーURL",
|
||||
"com_ui_medium": "中",
|
||||
"com_ui_memories": "メモリ",
|
||||
@@ -864,6 +883,7 @@
|
||||
"com_ui_memory_would_exceed": "保存できません - 制限を超えています {{tokens}} トークン。既存のメモリを削除してスペースを確保します。",
|
||||
"com_ui_mention": "エンドポイント、アシスタント、またはプリセットを素早く切り替えるには、それらを言及してください。",
|
||||
"com_ui_min_tags": "これ以上の値を削除できません。少なくとも {{0}} が必要です。",
|
||||
"com_ui_minimal": "最小限",
|
||||
"com_ui_misc": "その他",
|
||||
"com_ui_model": "モデル",
|
||||
"com_ui_model_parameters": "モデルパラメータ",
|
||||
@@ -899,6 +919,7 @@
|
||||
"com_ui_oauth_success_title": "認証成功",
|
||||
"com_ui_of": "of",
|
||||
"com_ui_off": "オフ",
|
||||
"com_ui_offline": "オフライン",
|
||||
"com_ui_on": "オン",
|
||||
"com_ui_openai": "OpenAI",
|
||||
"com_ui_optional": "(任意)",
|
||||
@@ -931,6 +952,7 @@
|
||||
"com_ui_regenerate_backup": "バックアップコードの再生成",
|
||||
"com_ui_regenerating": "再生成中...",
|
||||
"com_ui_region": "地域",
|
||||
"com_ui_reinitialize": "再初期化",
|
||||
"com_ui_rename": "タイトル変更",
|
||||
"com_ui_rename_conversation": "会話の名前を変更する",
|
||||
"com_ui_rename_failed": "会話の名前を変更できませんでした",
|
||||
@@ -970,6 +992,7 @@
|
||||
"com_ui_select_search_plugin": "プラグイン名で検索",
|
||||
"com_ui_select_search_provider": "プロバイダー名で検索",
|
||||
"com_ui_select_search_region": "地域名で検索",
|
||||
"com_ui_set": "セット",
|
||||
"com_ui_share": "共有",
|
||||
"com_ui_share_create_message": "あなたの名前と共有リンクを作成した後のメッセージは、共有されません。",
|
||||
"com_ui_share_delete_error": "共有リンクの削除中にエラーが発生しました。",
|
||||
@@ -1022,6 +1045,7 @@
|
||||
"com_ui_unarchive": "アーカイブ解除",
|
||||
"com_ui_unarchive_error": "アーカイブ解除に失敗しました。",
|
||||
"com_ui_unknown": "不明",
|
||||
"com_ui_unset": "設定解除",
|
||||
"com_ui_untitled": "無題",
|
||||
"com_ui_update": "更新",
|
||||
"com_ui_update_mcp_error": "MCPの作成または更新にエラーが発生しました。",
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"com_agents_mcp_icon_size": "최소 크기 128 x 128 픽셀",
|
||||
"com_agents_mcp_info": "에이전트가 작업을 수행하고 외부 서비스와 연동할 수 있도록 MCP 서버를 추가하세요",
|
||||
"com_agents_mcp_name_placeholder": "커스텀 툴",
|
||||
"com_agents_mcp_trust_subtext": "사용자 지정 커넥터는 LibreChat에서 확인되지 않습니다.",
|
||||
"com_agents_mcps_disabled": "MCP를 추가하려면 먼저 에이전트를 생성해야 합니다.",
|
||||
"com_agents_missing_provider_model": "에이전트를 생성하기 전에 제공업체와 모델을 선택해 주세요",
|
||||
"com_agents_name_placeholder": "선택 사항: 에이전트의 이름",
|
||||
"com_agents_no_access": "이 에이전트를 수정할 권한이 없습니다",
|
||||
@@ -337,6 +339,7 @@
|
||||
"com_nav_balance_minute": "분",
|
||||
"com_nav_balance_minutes": "분",
|
||||
"com_nav_balance_month": "월",
|
||||
"com_nav_balance_months": "월",
|
||||
"com_nav_balance_next_refill": "다음 충전:",
|
||||
"com_nav_balance_next_refill_info": "다음 충전은 두 조건이 모두 충족될 때만 자동으로 발생합니다: 마지막 충전 이후 지정된 시간 간격이 지났고, 프롬프트를 보내면 잔액이 0 아래로 떨어질 때입니다.",
|
||||
"com_nav_balance_refill_amount": "충전 금액:",
|
||||
@@ -549,7 +552,6 @@
|
||||
"com_ui_agent_var": "{{0}} 에이전트",
|
||||
"com_ui_agent_version": "버전",
|
||||
"com_ui_agent_version_active": "활성 버전",
|
||||
"com_ui_agent_version_duplicate": "중복 버전이 감지되었습니다. 이는 버전 {{versionIndex}}와 동일한 버전을 생성합니다.",
|
||||
"com_ui_agent_version_empty": "사용 가능한 버전이 없습니다",
|
||||
"com_ui_agent_version_error": "버전 정보 가져오기 오류",
|
||||
"com_ui_agent_version_history": "버전 기록",
|
||||
@@ -904,6 +906,7 @@
|
||||
"com_ui_oauth_connected_to": "연결됨:",
|
||||
"com_ui_oauth_error_callback_failed": "인증 콜백이 실패했습니다. 다시 시도하세요.",
|
||||
"com_ui_oauth_error_generic": "인증이 실패했습니다. 다시 시도하세요.",
|
||||
"com_ui_oauth_error_invalid_state": "잘못된 상태값입니다. 다시 시도하세요.",
|
||||
"com_ui_oauth_error_missing_code": "인증 코드가 누락되었습니다. 다시 시도하세요.",
|
||||
"com_ui_oauth_error_missing_state": "상태 파라미터가 누락되었습니다. 다시 시도하세요.",
|
||||
"com_ui_oauth_error_title": "인증 실패",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
"com_endpoint_prompt_prefix_assistants": "Ytterligare instruktioner",
|
||||
"com_endpoint_prompt_prefix_placeholder": "Ange anpassade instruktioner eller kontext. Ignoreras om tom.",
|
||||
"com_endpoint_save_as_preset": "Spara som förinställning",
|
||||
"com_endpoint_search": "Sök slutpunkt efter namn",
|
||||
"com_endpoint_search_endpoint_models": "Sök {{0}} modeller...",
|
||||
"com_endpoint_search_models": "Sök modeller...",
|
||||
"com_endpoint_search_var": "Sök {{0}}...",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--markdown-font-size: 1rem;
|
||||
}
|
||||
html {
|
||||
--brand-purple: #ab68ff;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
### ⚠️ Warning:
|
||||
|
||||
This script can be expensive, several dollars worth, even with prompt caching. It can also be slow if has not been ran in a while, with translations contributed.
|
||||
This script can be expensive, several dollars worth, even with prompt caching. It can also be slow if it has not been run in a while, with translations contributed.
|
||||
|
||||
### Instructions:
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// v0.8.0-rc1
|
||||
// v0.8.0-rc2
|
||||
// See .env.test.example for an example of the '.env.test' file.
|
||||
require('dotenv').config({ path: './e2e/.env.test' });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
apiVersion: v2
|
||||
name: librechat
|
||||
description: A Helm chart for LibreChat
|
||||
icon: https://www.librechat.ai/librechat_alt.svg
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
@@ -14,7 +15,7 @@ type: application
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 1.8.9
|
||||
version: 1.8.10
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
@@ -22,7 +23,7 @@ version: 1.8.9
|
||||
# It is recommended to use it with quotes.
|
||||
|
||||
# renovate: image=ghcr.io/danny-avila/librechat
|
||||
appVersion: "v0.8.0-rc1"
|
||||
appVersion: "v0.8.0-rc2"
|
||||
|
||||
home: https://www.librechat.ai
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
91
package-lock.json
generated
91
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "LibreChat",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "LibreChat",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"api",
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"api": {
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/openai": "^0.5.18",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.69",
|
||||
"@librechat/agents": "^2.4.75",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
@@ -2236,20 +2236,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"api/node_modules/express-rate-limit": {
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.4.1.tgz",
|
||||
"integrity": "sha512-KS3efpnpIDVIXopMc65EMbWbUht7qvTCdtCR2dD/IZmi9MIkopYESwyRqLgv8Pfu589+KqDqOdzJWW7AHoACeg==",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "4 || 5 || ^5.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"api/node_modules/express-session": {
|
||||
"version": "1.18.2",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
|
||||
@@ -2510,38 +2496,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"api/node_modules/mongodb-connection-string-url": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz",
|
||||
"integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==",
|
||||
"dependencies": {
|
||||
"@types/whatwg-url": "^11.0.2",
|
||||
"whatwg-url": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"api/node_modules/mongodb-connection-string-url/node_modules/tr46": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
|
||||
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"api/node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz",
|
||||
"integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==",
|
||||
"dependencies": {
|
||||
"tr46": "^4.1.1",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"api/node_modules/mongoose": {
|
||||
"version": "8.12.1",
|
||||
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.12.1.tgz",
|
||||
@@ -2735,7 +2689,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@ariakit/react": "^0.4.15",
|
||||
@@ -20124,9 +20078,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/anthropic": {
|
||||
"version": "0.3.24",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-0.3.24.tgz",
|
||||
"integrity": "sha512-Gi1TwXu5vkCxUMToiXaiwTTWq9v3WMyU3ldB/VEWjzbkr3nKF5kcp+HLqhvV7WWOFVTTNgG+pzfq8JALecq5MA==",
|
||||
"version": "0.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-0.3.26.tgz",
|
||||
"integrity": "sha512-IRCjkxsMx6MZUZmv/aYX5A9RdIduzdR0eeOc4rX8waBcYP7qmtA/CUTNmTtMSoXfOfJY4s3414bkVNBkmS0+5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.56.0",
|
||||
@@ -21573,12 +21527,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@librechat/agents": {
|
||||
"version": "2.4.69",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.69.tgz",
|
||||
"integrity": "sha512-Yt0rttqOaZQeZPIB68I8RdnU6SHeh0OJV5yEg8mx9EHTA7SnV/lOlDhn424aXdpMvYZYuxAt/Fev3jTC7qKiTg==",
|
||||
"version": "2.4.75",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.75.tgz",
|
||||
"integrity": "sha512-GueaA5WAc0nliuQjqbqBVAR/7/qaFw8xpg5ClaFHbm5YseyKF+iuSg+sBaF0eo2ceswO3nEmdLa3QtIhKXsQgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^0.3.24",
|
||||
"@langchain/anthropic": "^0.3.26",
|
||||
"@langchain/aws": "^0.1.12",
|
||||
"@langchain/community": "^0.3.47",
|
||||
"@langchain/core": "^0.3.62",
|
||||
@@ -29450,7 +29404,7 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "2.0.10",
|
||||
@@ -33161,6 +33115,16 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
||||
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
@@ -51381,7 +51345,7 @@
|
||||
},
|
||||
"packages/api": {
|
||||
"name": "@librechat/api",
|
||||
"version": "1.2.9",
|
||||
"version": "1.3.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.21.5",
|
||||
@@ -51414,7 +51378,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@librechat/agents": "^2.4.69",
|
||||
"@librechat/agents": "^2.4.75",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
"axios": "^1.8.2",
|
||||
@@ -51507,7 +51471,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@librechat/client",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.2",
|
||||
@@ -51560,6 +51524,7 @@
|
||||
"@tanstack/react-virtual": "^3.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.2.6",
|
||||
"framer-motion": "^12.23.6",
|
||||
"i18next": "^24.2.2 || ^25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -51805,7 +51770,7 @@
|
||||
},
|
||||
"packages/data-provider": {
|
||||
"name": "librechat-data-provider",
|
||||
"version": "0.7.903",
|
||||
"version": "0.8.001",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "LibreChat",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"description": "",
|
||||
"workspaces": [
|
||||
"api",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { ViolationTypes, ErrorTypes } from 'librechat-data-provider';
|
||||
import type { Agent, TModelsConfig } from 'librechat-data-provider';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
/** Avatar schema shared between create and update */
|
||||
export const agentAvatarSchema = z.object({
|
||||
@@ -59,3 +62,90 @@ export const agentUpdateSchema = agentBaseSchema.extend({
|
||||
removeProjectIds: z.array(z.string()).optional(),
|
||||
isCollaborative: z.boolean().optional(),
|
||||
});
|
||||
|
||||
interface ValidateAgentModelParams {
|
||||
req: Request;
|
||||
res: Response;
|
||||
agent: Agent;
|
||||
modelsConfig: TModelsConfig;
|
||||
logViolation: (
|
||||
req: Request,
|
||||
res: Response,
|
||||
type: string,
|
||||
errorMessage: Record<string, unknown>,
|
||||
score?: number | string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
interface ValidateAgentModelResult {
|
||||
isValid: boolean;
|
||||
error?: {
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an agent's model against the available models configuration.
|
||||
* This is a non-middleware version of validateModel that can be used
|
||||
* in service initialization flows.
|
||||
*
|
||||
* @param params - Validation parameters
|
||||
* @returns Object indicating whether the model is valid and any error details
|
||||
*/
|
||||
export async function validateAgentModel(
|
||||
params: ValidateAgentModelParams,
|
||||
): Promise<ValidateAgentModelResult> {
|
||||
const { req, res, agent, modelsConfig, logViolation } = params;
|
||||
const { model, provider: endpoint } = agent;
|
||||
|
||||
if (!model) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
message: `{ "type": "${ErrorTypes.MISSING_MODEL}", "info": "${endpoint}" }`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!modelsConfig) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
message: `{ "type": "${ErrorTypes.MODELS_NOT_LOADED}" }`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const availableModels = modelsConfig[endpoint];
|
||||
if (!availableModels) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
message: `{ "type": "${ErrorTypes.ENDPOINT_MODELS_NOT_LOADED}", "info": "${endpoint}" }`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const validModel = !!availableModels.find((availableModel) => availableModel === model);
|
||||
|
||||
if (validModel) {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
const { ILLEGAL_MODEL_REQ_SCORE: score = 1 } = process.env ?? {};
|
||||
const type = ViolationTypes.ILLEGAL_MODEL_REQUEST;
|
||||
const errorMessage = {
|
||||
type,
|
||||
model,
|
||||
endpoint,
|
||||
};
|
||||
|
||||
await logViolation(req, res, type, errorMessage, score);
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
message: `{ "type": "${ViolationTypes.ILLEGAL_MODEL_REQUEST}", "info": "${endpoint}|${model}" }`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,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,
|
||||
@@ -44,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(
|
||||
@@ -55,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;
|
||||
@@ -223,6 +294,23 @@ export function getOpenAIConfig(
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -15,12 +15,16 @@ export * from './crypto';
|
||||
export * from './flow/manager';
|
||||
/* Middleware */
|
||||
export * from './middleware';
|
||||
/* Memory */
|
||||
export * from './memory';
|
||||
/* Agents */
|
||||
export * from './agents';
|
||||
/* Endpoints */
|
||||
export * from './endpoints';
|
||||
/* Files */
|
||||
export * from './files';
|
||||
/* Tools */
|
||||
export * from './tools';
|
||||
/* web search */
|
||||
export * from './web';
|
||||
/* types */
|
||||
|
||||
28
packages/api/src/memory/config.ts
Normal file
28
packages/api/src/memory/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { memorySchema } from 'librechat-data-provider';
|
||||
import type { TCustomConfig, TMemoryConfig } from 'librechat-data-provider';
|
||||
|
||||
const hasValidAgent = (agent: TMemoryConfig['agent']) =>
|
||||
!!agent &&
|
||||
(('id' in agent && !!agent.id) ||
|
||||
('provider' in agent && 'model' in agent && !!agent.provider && !!agent.model));
|
||||
|
||||
const isDisabled = (config?: TMemoryConfig | TCustomConfig['memory']) =>
|
||||
!config || config.disabled === true;
|
||||
|
||||
export function loadMemoryConfig(config: TCustomConfig['memory']): TMemoryConfig | undefined {
|
||||
if (!config) return undefined;
|
||||
if (isDisabled(config)) return config as TMemoryConfig;
|
||||
|
||||
if (!hasValidAgent(config.agent)) {
|
||||
return { ...config, disabled: true } as TMemoryConfig;
|
||||
}
|
||||
|
||||
const charLimit = memorySchema.shape.charLimit.safeParse(config.charLimit).data ?? 10000;
|
||||
|
||||
return { ...config, charLimit };
|
||||
}
|
||||
|
||||
export function isMemoryEnabled(config: TMemoryConfig | undefined): boolean {
|
||||
if (isDisabled(config)) return false;
|
||||
return hasValidAgent(config!.agent);
|
||||
}
|
||||
1
packages/api/src/memory/index.ts
Normal file
1
packages/api/src/memory/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './config';
|
||||
@@ -1,36 +1,43 @@
|
||||
const errorController = require('./ErrorController');
|
||||
const { logger } = require('~/config');
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { ErrorController } from './error';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { ValidationError, MongoServerError, CustomError } from '~/types';
|
||||
|
||||
// Mock the logger
|
||||
jest.mock('~/config', () => ({
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ErrorController', () => {
|
||||
let mockReq, mockRes, mockNext;
|
||||
let mockReq: Request;
|
||||
let mockRes: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReq = {};
|
||||
mockReq = {
|
||||
originalUrl: '',
|
||||
} as Request;
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mockNext = jest.fn();
|
||||
logger.error.mockClear();
|
||||
} as unknown as Response;
|
||||
(logger.error as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('ValidationError handling', () => {
|
||||
it('should handle ValidationError with single error', () => {
|
||||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
message: 'Validation error',
|
||||
errors: {
|
||||
email: { message: 'Email is required', path: 'email' },
|
||||
},
|
||||
};
|
||||
} as ValidationError;
|
||||
|
||||
errorController(validationError, mockReq, mockRes, mockNext);
|
||||
ErrorController(validationError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
@@ -43,13 +50,14 @@ describe('ErrorController', () => {
|
||||
it('should handle ValidationError with multiple errors', () => {
|
||||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
message: 'Validation error',
|
||||
errors: {
|
||||
email: { message: 'Email is required', path: 'email' },
|
||||
password: { message: 'Password is required', path: 'password' },
|
||||
},
|
||||
};
|
||||
} as ValidationError;
|
||||
|
||||
errorController(validationError, mockReq, mockRes, mockNext);
|
||||
ErrorController(validationError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
@@ -63,9 +71,9 @@ describe('ErrorController', () => {
|
||||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
errors: {},
|
||||
};
|
||||
} as ValidationError;
|
||||
|
||||
errorController(validationError, mockReq, mockRes, mockNext);
|
||||
ErrorController(validationError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
@@ -78,43 +86,59 @@ describe('ErrorController', () => {
|
||||
describe('Duplicate key error handling', () => {
|
||||
it('should handle duplicate key error (code 11000)', () => {
|
||||
const duplicateKeyError = {
|
||||
name: 'MongoServerError',
|
||||
message: 'Duplicate key error',
|
||||
code: 11000,
|
||||
keyValue: { email: 'test@example.com' },
|
||||
};
|
||||
errmsg:
|
||||
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
} as MongoServerError;
|
||||
|
||||
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
|
||||
ErrorController(duplicateKeyError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: 'An document with that ["email"] already exists.',
|
||||
fields: '["email"]',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith('Duplicate key error:', duplicateKeyError.keyValue);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Duplicate key error: E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle duplicate key error with multiple fields', () => {
|
||||
const duplicateKeyError = {
|
||||
name: 'MongoServerError',
|
||||
message: 'Duplicate key error',
|
||||
code: 11000,
|
||||
keyValue: { email: 'test@example.com', username: 'testuser' },
|
||||
};
|
||||
errmsg:
|
||||
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
} as MongoServerError;
|
||||
|
||||
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
|
||||
ErrorController(duplicateKeyError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: 'An document with that ["email","username"] already exists.',
|
||||
fields: '["email","username"]',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith('Duplicate key error:', duplicateKeyError.keyValue);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Duplicate key error: E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error with code 11000 as string', () => {
|
||||
const duplicateKeyError = {
|
||||
code: '11000',
|
||||
name: 'MongoServerError',
|
||||
message: 'Duplicate key error',
|
||||
code: 11000,
|
||||
keyValue: { email: 'test@example.com' },
|
||||
};
|
||||
errmsg:
|
||||
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
} as MongoServerError;
|
||||
|
||||
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
|
||||
ErrorController(duplicateKeyError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
@@ -129,9 +153,9 @@ describe('ErrorController', () => {
|
||||
const syntaxError = {
|
||||
statusCode: 400,
|
||||
body: 'Invalid JSON syntax',
|
||||
};
|
||||
} as CustomError;
|
||||
|
||||
errorController(syntaxError, mockReq, mockRes, mockNext);
|
||||
ErrorController(syntaxError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('Invalid JSON syntax');
|
||||
@@ -141,9 +165,9 @@ describe('ErrorController', () => {
|
||||
const customError = {
|
||||
statusCode: 422,
|
||||
body: { error: 'Unprocessable entity' },
|
||||
};
|
||||
} as CustomError;
|
||||
|
||||
errorController(customError, mockReq, mockRes, mockNext);
|
||||
ErrorController(customError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(422);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({ error: 'Unprocessable entity' });
|
||||
@@ -152,9 +176,9 @@ describe('ErrorController', () => {
|
||||
it('should handle error with statusCode but no body', () => {
|
||||
const partialError = {
|
||||
statusCode: 400,
|
||||
};
|
||||
} as CustomError;
|
||||
|
||||
errorController(partialError, mockReq, mockRes, mockNext);
|
||||
ErrorController(partialError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
@@ -163,9 +187,9 @@ describe('ErrorController', () => {
|
||||
it('should handle error with body but no statusCode', () => {
|
||||
const partialError = {
|
||||
body: 'Some error message',
|
||||
};
|
||||
} as CustomError;
|
||||
|
||||
errorController(partialError, mockReq, mockRes, mockNext);
|
||||
ErrorController(partialError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
@@ -176,7 +200,7 @@ describe('ErrorController', () => {
|
||||
it('should handle unknown errors', () => {
|
||||
const unknownError = new Error('Some unknown error');
|
||||
|
||||
errorController(unknownError, mockReq, mockRes, mockNext);
|
||||
ErrorController(unknownError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
@@ -187,32 +211,31 @@ describe('ErrorController', () => {
|
||||
const mongoError = {
|
||||
code: 11100,
|
||||
message: 'Some MongoDB error',
|
||||
};
|
||||
} as MongoServerError;
|
||||
|
||||
errorController(mongoError, mockReq, mockRes, mockNext);
|
||||
ErrorController(mongoError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', mongoError);
|
||||
});
|
||||
|
||||
it('should handle null/undefined errors', () => {
|
||||
errorController(null, mockReq, mockRes, mockNext);
|
||||
it('should handle generic errors', () => {
|
||||
const genericError = new Error('Test error');
|
||||
|
||||
ErrorController(genericError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'ErrorController => processing error',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', genericError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Catch block handling', () => {
|
||||
beforeEach(() => {
|
||||
// Restore logger mock to normal behavior for these tests
|
||||
logger.error.mockRestore();
|
||||
logger.error = jest.fn();
|
||||
(logger.error as jest.Mock).mockRestore();
|
||||
(logger.error as jest.Mock) = jest.fn();
|
||||
});
|
||||
|
||||
it('should handle errors when logger.error throws', () => {
|
||||
@@ -220,10 +243,10 @@ describe('ErrorController', () => {
|
||||
const freshMockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
} as unknown as Response;
|
||||
|
||||
// Mock logger to throw on the first call, succeed on the second
|
||||
logger.error
|
||||
(logger.error as jest.Mock)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Logger error');
|
||||
})
|
||||
@@ -231,7 +254,7 @@ describe('ErrorController', () => {
|
||||
|
||||
const testError = new Error('Test error');
|
||||
|
||||
errorController(testError, mockReq, freshMockRes, mockNext);
|
||||
ErrorController(testError, mockReq, freshMockRes);
|
||||
|
||||
expect(freshMockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(freshMockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');
|
||||
83
packages/api/src/middleware/error.ts
Normal file
83
packages/api/src/middleware/error.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { ErrorTypes } from 'librechat-data-provider';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { MongoServerError, ValidationError, CustomError } from '~/types';
|
||||
|
||||
const handleDuplicateKeyError = (err: MongoServerError, res: Response) => {
|
||||
logger.warn('Duplicate key error: ' + (err.errmsg || err.message));
|
||||
const field = err.keyValue ? `${JSON.stringify(Object.keys(err.keyValue))}` : 'unknown';
|
||||
const code = 409;
|
||||
res
|
||||
.status(code)
|
||||
.send({ messages: `An document with that ${field} already exists.`, fields: field });
|
||||
};
|
||||
|
||||
const handleValidationError = (err: ValidationError, res: Response) => {
|
||||
logger.error('Validation error:', err.errors);
|
||||
const errorMessages = Object.values(err.errors).map((el) => el.message);
|
||||
const fields = `${JSON.stringify(Object.values(err.errors).map((el) => el.path))}`;
|
||||
const code = 400;
|
||||
const messages =
|
||||
errorMessages.length > 1
|
||||
? `${JSON.stringify(errorMessages.join(' '))}`
|
||||
: `${JSON.stringify(errorMessages)}`;
|
||||
|
||||
res.status(code).send({ messages, fields });
|
||||
};
|
||||
|
||||
/** Type guard for ValidationError */
|
||||
function isValidationError(err: unknown): err is ValidationError {
|
||||
return err !== null && typeof err === 'object' && 'name' in err && err.name === 'ValidationError';
|
||||
}
|
||||
|
||||
/** Type guard for MongoServerError (duplicate key) */
|
||||
function isMongoServerError(err: unknown): err is MongoServerError {
|
||||
return err !== null && typeof err === 'object' && 'code' in err && err.code === 11000;
|
||||
}
|
||||
|
||||
/** Type guard for CustomError with statusCode and body */
|
||||
function isCustomError(err: unknown): err is CustomError {
|
||||
return err !== null && typeof err === 'object' && 'statusCode' in err && 'body' in err;
|
||||
}
|
||||
|
||||
export const ErrorController = (
|
||||
err: Error | CustomError,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Response | void => {
|
||||
try {
|
||||
if (!err) {
|
||||
return next();
|
||||
}
|
||||
const error = err as CustomError;
|
||||
|
||||
if (
|
||||
(error.message === ErrorTypes.AUTH_FAILED || error.code === ErrorTypes.AUTH_FAILED) &&
|
||||
req.originalUrl &&
|
||||
req.originalUrl.includes('/oauth/') &&
|
||||
req.originalUrl.includes('/callback')
|
||||
) {
|
||||
const domain = process.env.DOMAIN_CLIENT || 'http://localhost:3080';
|
||||
return res.redirect(`${domain}/login?redirect=false&error=${ErrorTypes.AUTH_FAILED}`);
|
||||
}
|
||||
|
||||
if (isValidationError(error)) {
|
||||
return handleValidationError(error, res);
|
||||
}
|
||||
|
||||
if (isMongoServerError(error)) {
|
||||
return handleDuplicateKeyError(error, res);
|
||||
}
|
||||
|
||||
if (isCustomError(error) && error.statusCode && error.body) {
|
||||
return res.status(error.statusCode).send(error.body);
|
||||
}
|
||||
|
||||
logger.error('ErrorController => error', err);
|
||||
return res.status(500).send('An unknown error occurred.');
|
||||
} catch (processingError) {
|
||||
logger.error('ErrorController => processing error', processingError);
|
||||
return res.status(500).send('Processing error in ErrorController.');
|
||||
}
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from './access';
|
||||
export * from './error';
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user