Merge branch 'dev' into feat/prompt-enhancement

This commit is contained in:
Marco Beretta 2025-07-10 12:11:22 +02:00 committed by GitHub
commit e1af9d21f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
309 changed files with 12487 additions and 6311 deletions

View file

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

View file

@ -1,4 +1,3 @@
/* eslint-disable jest/no-conditional-expect */
import { ZodError, z } from 'zod';
import { generateDynamicSchema, validateSettingDefinitions, OptionTypes } from '../src/generate';
import type { SettingsConfiguration } from '../src/generate';
@ -97,6 +96,37 @@ describe('generateDynamicSchema', () => {
expect(result['data']).toEqual({ testEnum: 'option2' });
});
it('should generate a schema for enum settings with empty string option', () => {
const settings: SettingsConfiguration = [
{
key: 'testEnumWithEmpty',
description: 'A test enum setting with empty string',
type: 'enum',
default: '',
options: ['', 'option1', 'option2'],
enumMappings: {
'': 'None',
option1: 'First Option',
option2: 'Second Option',
},
component: 'slider',
columnSpan: 2,
label: 'Test Enum with Empty String',
},
];
const schema = generateDynamicSchema(settings);
const result = schema.safeParse({ testEnumWithEmpty: '' });
expect(result.success).toBeTruthy();
expect(result['data']).toEqual({ testEnumWithEmpty: '' });
// Test with non-empty option
const result2 = schema.safeParse({ testEnumWithEmpty: 'option1' });
expect(result2.success).toBeTruthy();
expect(result2['data']).toEqual({ testEnumWithEmpty: 'option1' });
});
it('should fail for incorrect enum value', () => {
const settings: SettingsConfiguration = [
{
@ -481,6 +511,47 @@ describe('validateSettingDefinitions', () => {
expect(() => validateSettingDefinitions(settingsExceedingMaxTags)).toThrow(ZodError);
});
// Test for incomplete enumMappings
test('should throw error for incomplete enumMappings', () => {
const settingsWithIncompleteEnumMappings: SettingsConfiguration = [
{
key: 'displayMode',
type: 'enum',
component: 'dropdown',
options: ['light', 'dark', 'auto'],
enumMappings: {
light: 'Light Mode',
dark: 'Dark Mode',
// Missing mapping for 'auto'
},
optionType: OptionTypes.Custom,
},
];
expect(() => validateSettingDefinitions(settingsWithIncompleteEnumMappings)).toThrow(ZodError);
});
// Test for complete enumMappings including empty string
test('should not throw error for complete enumMappings including empty string', () => {
const settingsWithCompleteEnumMappings: SettingsConfiguration = [
{
key: 'selectionMode',
type: 'enum',
component: 'slider',
options: ['', 'single', 'multiple'],
enumMappings: {
'': 'None',
single: 'Single Selection',
multiple: 'Multiple Selection',
},
default: '',
optionType: OptionTypes.Custom,
},
];
expect(() => validateSettingDefinitions(settingsWithCompleteEnumMappings)).not.toThrow();
});
});
const settingsConfiguration: SettingsConfiguration = [
@ -515,7 +586,7 @@ const settingsConfiguration: SettingsConfiguration = [
{
key: 'presence_penalty',
description:
'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.',
"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.",
type: 'number',
default: 0,
range: {
@ -529,7 +600,7 @@ const settingsConfiguration: SettingsConfiguration = [
{
key: 'frequency_penalty',
description:
'Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model\'s likelihood to repeat the same line verbatim.',
"Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.",
type: 'number',
default: 0,
range: {

View file

@ -1,712 +0,0 @@
import type { TUser } from 'librechat-data-provider';
import {
StreamableHTTPOptionsSchema,
StdioOptionsSchema,
processMCPEnv,
MCPOptions,
} from '../src/mcp';
// Helper function to create test user objects
function createTestUser(
overrides: Partial<TUser> & Record<string, unknown> = {},
): TUser & Record<string, unknown> {
return {
id: 'test-user-id',
username: 'testuser',
email: 'test@example.com',
name: 'Test User',
avatar: 'https://example.com/avatar.png',
provider: 'email',
role: 'user',
createdAt: new Date('2021-01-01').toISOString(),
updatedAt: new Date('2021-01-01').toISOString(),
...overrides,
};
}
describe('Environment Variable Extraction (MCP)', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = {
...originalEnv,
TEST_API_KEY: 'test-api-key-value',
ANOTHER_SECRET: 'another-secret-value',
};
});
afterEach(() => {
process.env = originalEnv;
});
describe('StdioOptionsSchema', () => {
it('should transform environment variables in the env field', () => {
const options = {
command: 'node',
args: ['server.js'],
env: {
API_KEY: '${TEST_API_KEY}',
ANOTHER_KEY: '${ANOTHER_SECRET}',
PLAIN_VALUE: 'plain-value',
NON_EXISTENT: '${NON_EXISTENT_VAR}',
},
};
const result = StdioOptionsSchema.parse(options);
expect(result.env).toEqual({
API_KEY: 'test-api-key-value',
ANOTHER_KEY: 'another-secret-value',
PLAIN_VALUE: 'plain-value',
NON_EXISTENT: '${NON_EXISTENT_VAR}',
});
});
it('should handle undefined env field', () => {
const options = {
command: 'node',
args: ['server.js'],
};
const result = StdioOptionsSchema.parse(options);
expect(result.env).toBeUndefined();
});
});
describe('StreamableHTTPOptionsSchema', () => {
it('should validate a valid streamable-http configuration', () => {
const options = {
type: 'streamable-http',
url: 'https://example.com/api',
headers: {
Authorization: 'Bearer token',
'Content-Type': 'application/json',
},
};
const result = StreamableHTTPOptionsSchema.parse(options);
expect(result).toEqual(options);
});
it('should reject websocket URLs', () => {
const options = {
type: 'streamable-http',
url: 'ws://example.com/socket',
};
expect(() => StreamableHTTPOptionsSchema.parse(options)).toThrow();
});
it('should reject secure websocket URLs', () => {
const options = {
type: 'streamable-http',
url: 'wss://example.com/socket',
};
expect(() => StreamableHTTPOptionsSchema.parse(options)).toThrow();
});
it('should require type field to be set explicitly', () => {
const options = {
url: 'https://example.com/api',
};
// Type is now required, so parsing should fail
expect(() => StreamableHTTPOptionsSchema.parse(options)).toThrow();
// With type provided, it should pass
const validOptions = {
type: 'streamable-http' as const,
url: 'https://example.com/api',
};
const result = StreamableHTTPOptionsSchema.parse(validOptions);
expect(result.type).toBe('streamable-http');
});
it('should validate headers as record of strings', () => {
const options = {
type: 'streamable-http',
url: 'https://example.com/api',
headers: {
'X-API-Key': '123456',
'User-Agent': 'MCP Client',
},
};
const result = StreamableHTTPOptionsSchema.parse(options);
expect(result.headers).toEqual(options.headers);
});
});
describe('processMCPEnv', () => {
it('should create a deep clone of the input object', () => {
const originalObj: MCPOptions = {
command: 'node',
args: ['server.js'],
env: {
API_KEY: '${TEST_API_KEY}',
PLAIN_VALUE: 'plain-value',
},
};
const result = processMCPEnv(originalObj);
// Verify it's not the same object reference
expect(result).not.toBe(originalObj);
// Modify the result and ensure original is unchanged
if ('env' in result && result.env) {
result.env.API_KEY = 'modified-value';
}
expect(originalObj.env?.API_KEY).toBe('${TEST_API_KEY}');
});
it('should process environment variables in env field', () => {
const obj: MCPOptions = {
command: 'node',
args: ['server.js'],
env: {
API_KEY: '${TEST_API_KEY}',
ANOTHER_KEY: '${ANOTHER_SECRET}',
PLAIN_VALUE: 'plain-value',
NON_EXISTENT: '${NON_EXISTENT_VAR}',
},
};
const result = processMCPEnv(obj);
expect('env' in result && result.env).toEqual({
API_KEY: 'test-api-key-value',
ANOTHER_KEY: 'another-secret-value',
PLAIN_VALUE: 'plain-value',
NON_EXISTENT: '${NON_EXISTENT_VAR}',
});
});
it('should process user ID in headers field', () => {
const user = createTestUser({ id: 'test-user-123' });
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
Authorization: '${TEST_API_KEY}',
'User-Id': '{{LIBRECHAT_USER_ID}}',
'Content-Type': 'application/json',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
Authorization: 'test-api-key-value',
'User-Id': 'test-user-123',
'Content-Type': 'application/json',
});
});
it('should handle null or undefined input', () => {
// @ts-ignore - Testing null/undefined handling
expect(processMCPEnv(null)).toBeNull();
// @ts-ignore - Testing null/undefined handling
expect(processMCPEnv(undefined)).toBeUndefined();
});
it('should not modify objects without env or headers', () => {
const obj: MCPOptions = {
command: 'node',
args: ['server.js'],
timeout: 5000,
};
const result = processMCPEnv(obj);
expect(result).toEqual(obj);
expect(result).not.toBe(obj); // Still a different object (deep clone)
});
it('should ensure different users with same starting config get separate values', () => {
// Create a single base configuration
const baseConfig: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Id': '{{LIBRECHAT_USER_ID}}',
'API-Key': '${TEST_API_KEY}',
},
};
// Process for two different users
const user1 = createTestUser({ id: 'user-123' });
const user2 = createTestUser({ id: 'user-456' });
const resultUser1 = processMCPEnv(baseConfig, user1);
const resultUser2 = processMCPEnv(baseConfig, user2);
// Verify each has the correct user ID
expect('headers' in resultUser1 && resultUser1.headers?.['User-Id']).toBe('user-123');
expect('headers' in resultUser2 && resultUser2.headers?.['User-Id']).toBe('user-456');
// Verify they're different objects
expect(resultUser1).not.toBe(resultUser2);
// Modify one result and ensure it doesn't affect the other
if ('headers' in resultUser1 && resultUser1.headers) {
resultUser1.headers['User-Id'] = 'modified-user';
}
// Original config should be unchanged
expect(baseConfig.headers?.['User-Id']).toBe('{{LIBRECHAT_USER_ID}}');
// Second user's config should be unchanged
expect('headers' in resultUser2 && resultUser2.headers?.['User-Id']).toBe('user-456');
});
it('should process headers in streamable-http options', () => {
const user = createTestUser({ id: 'test-user-123' });
const obj: MCPOptions = {
type: 'streamable-http',
url: 'https://example.com',
headers: {
Authorization: '${TEST_API_KEY}',
'User-Id': '{{LIBRECHAT_USER_ID}}',
'Content-Type': 'application/json',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
Authorization: 'test-api-key-value',
'User-Id': 'test-user-123',
'Content-Type': 'application/json',
});
});
it('should maintain streamable-http type in processed options', () => {
const obj: MCPOptions = {
type: 'streamable-http',
url: 'https://example.com/api',
};
const result = processMCPEnv(obj);
expect(result.type).toBe('streamable-http');
});
it('should process dynamic user fields in headers', () => {
const user = createTestUser({
id: 'user-123',
email: 'test@example.com',
username: 'testuser',
openidId: 'openid-123',
googleId: 'google-456',
emailVerified: true,
role: 'admin',
});
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Email': '{{LIBRECHAT_USER_EMAIL}}',
'User-Name': '{{LIBRECHAT_USER_USERNAME}}',
OpenID: '{{LIBRECHAT_USER_OPENIDID}}',
'Google-ID': '{{LIBRECHAT_USER_GOOGLEID}}',
'Email-Verified': '{{LIBRECHAT_USER_EMAILVERIFIED}}',
'User-Role': '{{LIBRECHAT_USER_ROLE}}',
'Content-Type': 'application/json',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
'User-Email': 'test@example.com',
'User-Name': 'testuser',
OpenID: 'openid-123',
'Google-ID': 'google-456',
'Email-Verified': 'true',
'User-Role': 'admin',
'Content-Type': 'application/json',
});
});
it('should handle missing user fields gracefully', () => {
const user = createTestUser({
id: 'user-123',
email: 'test@example.com',
username: undefined, // explicitly set to undefined to test missing field
});
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Email': '{{LIBRECHAT_USER_EMAIL}}',
'User-Name': '{{LIBRECHAT_USER_USERNAME}}',
'Content-Type': 'application/json',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
'User-Email': 'test@example.com',
'User-Name': '', // Empty string for missing field
'Content-Type': 'application/json',
});
});
it('should process user fields in env variables', () => {
const user = createTestUser({
id: 'user-123',
email: 'test@example.com',
ldapId: 'ldap-user-123',
});
const obj: MCPOptions = {
command: 'node',
args: ['server.js'],
env: {
USER_EMAIL: '{{LIBRECHAT_USER_EMAIL}}',
LDAP_ID: '{{LIBRECHAT_USER_LDAPID}}',
API_KEY: '${TEST_API_KEY}',
},
};
const result = processMCPEnv(obj, user);
expect('env' in result && result.env).toEqual({
USER_EMAIL: 'test@example.com',
LDAP_ID: 'ldap-user-123',
API_KEY: 'test-api-key-value',
});
});
it('should process user fields in URL', () => {
const user = createTestUser({
id: 'user-123',
username: 'testuser',
});
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com/api/{{LIBRECHAT_USER_USERNAME}}/stream',
};
const result = processMCPEnv(obj, user);
expect('url' in result && result.url).toBe('https://example.com/api/testuser/stream');
});
it('should handle boolean user fields', () => {
const user = createTestUser({
id: 'user-123',
emailVerified: true,
twoFactorEnabled: false,
termsAccepted: true,
});
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'Email-Verified': '{{LIBRECHAT_USER_EMAILVERIFIED}}',
'Two-Factor': '{{LIBRECHAT_USER_TWOFACTORENABLED}}',
'Terms-Accepted': '{{LIBRECHAT_USER_TERMSACCEPTED}}',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
'Email-Verified': 'true',
'Two-Factor': 'false',
'Terms-Accepted': 'true',
});
});
it('should not process sensitive fields like password', () => {
const user = createTestUser({
id: 'user-123',
email: 'test@example.com',
password: 'secret-password',
});
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Email': '{{LIBRECHAT_USER_EMAIL}}',
'User-Password': '{{LIBRECHAT_USER_PASSWORD}}', // This should not be processed
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
'User-Email': 'test@example.com',
'User-Password': '{{LIBRECHAT_USER_PASSWORD}}', // Unchanged
});
});
it('should handle multiple occurrences of the same placeholder', () => {
const user = createTestUser({
id: 'user-123',
email: 'test@example.com',
});
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'Primary-Email': '{{LIBRECHAT_USER_EMAIL}}',
'Secondary-Email': '{{LIBRECHAT_USER_EMAIL}}',
'Backup-Email': '{{LIBRECHAT_USER_EMAIL}}',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
'Primary-Email': 'test@example.com',
'Secondary-Email': 'test@example.com',
'Backup-Email': 'test@example.com',
});
});
it('should support both id and _id properties for LIBRECHAT_USER_ID', () => {
// Test with 'id' property
const userWithId = createTestUser({
id: 'user-123',
email: 'test@example.com',
});
const obj1: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Id': '{{LIBRECHAT_USER_ID}}',
},
};
const result1 = processMCPEnv(obj1, userWithId);
expect('headers' in result1 && result1.headers?.['User-Id']).toBe('user-123');
// Test with '_id' property only (should not work since we only check 'id')
const userWithUnderscore = createTestUser({
id: undefined, // Remove default id to test _id
_id: 'user-456',
email: 'test@example.com',
});
const obj2: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Id': '{{LIBRECHAT_USER_ID}}',
},
};
const result2 = processMCPEnv(obj2, userWithUnderscore);
// Since we don't check _id, the placeholder should remain unchanged
expect('headers' in result2 && result2.headers?.['User-Id']).toBe('{{LIBRECHAT_USER_ID}}');
// Test with both properties (id takes precedence)
const userWithBoth = createTestUser({
id: 'user-789',
_id: 'user-000',
email: 'test@example.com',
});
const obj3: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Id': '{{LIBRECHAT_USER_ID}}',
},
};
const result3 = processMCPEnv(obj3, userWithBoth);
expect('headers' in result3 && result3.headers?.['User-Id']).toBe('user-789');
});
it('should process customUserVars in env field', () => {
const user = createTestUser();
const customUserVars = {
CUSTOM_VAR_1: 'custom-value-1',
CUSTOM_VAR_2: 'custom-value-2',
};
const obj: MCPOptions = {
command: 'node',
args: ['server.js'],
env: {
VAR_A: '{{CUSTOM_VAR_1}}',
VAR_B: 'Value with {{CUSTOM_VAR_2}}',
VAR_C: '${TEST_API_KEY}',
VAR_D: '{{LIBRECHAT_USER_EMAIL}}',
},
};
const result = processMCPEnv(obj, user, customUserVars);
expect('env' in result && result.env).toEqual({
VAR_A: 'custom-value-1',
VAR_B: 'Value with custom-value-2',
VAR_C: 'test-api-key-value',
VAR_D: 'test@example.com',
});
});
it('should process customUserVars in headers field', () => {
const user = createTestUser();
const customUserVars = {
USER_TOKEN: 'user-specific-token',
REGION: 'us-west-1',
};
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com/api',
headers: {
Authorization: 'Bearer {{USER_TOKEN}}',
'X-Region': '{{REGION}}',
'X-System-Key': '${TEST_API_KEY}',
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
},
};
const result = processMCPEnv(obj, user, customUserVars);
expect('headers' in result && result.headers).toEqual({
Authorization: 'Bearer user-specific-token',
'X-Region': 'us-west-1',
'X-System-Key': 'test-api-key-value',
'X-User-Id': 'test-user-id',
});
});
it('should process customUserVars in URL field', () => {
const user = createTestUser();
const customUserVars = {
API_VERSION: 'v2',
TENANT_ID: 'tenant123',
};
const obj: MCPOptions = {
type: 'websocket',
url: 'wss://example.com/{{TENANT_ID}}/api/{{API_VERSION}}?user={{LIBRECHAT_USER_ID}}&key=${TEST_API_KEY}',
};
const result = processMCPEnv(obj, user, customUserVars);
expect('url' in result && result.url).toBe(
'wss://example.com/tenant123/api/v2?user=test-user-id&key=test-api-key-value',
);
});
it('should prioritize customUserVars over user fields and system env vars if placeholders are the same (though not recommended)', () => {
// This tests the order of operations: customUserVars -> userFields -> systemEnv
// BUt it's generally not recommended to have overlapping placeholder names.
process.env.LIBRECHAT_USER_EMAIL = 'system-email-should-be-overridden';
const user = createTestUser({ email: 'user-email-should-be-overridden' });
const customUserVars = {
LIBRECHAT_USER_EMAIL: 'custom-email-wins',
};
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com/api',
headers: {
'Test-Email': '{{LIBRECHAT_USER_EMAIL}}', // Placeholder that could match custom, user, or system
},
};
const result = processMCPEnv(obj, user, customUserVars);
expect('headers' in result && result.headers?.['Test-Email']).toBe('custom-email-wins');
// Clean up env var
delete process.env.LIBRECHAT_USER_EMAIL;
});
it('should handle customUserVars with no matching placeholders', () => {
const user = createTestUser();
const customUserVars = {
UNUSED_VAR: 'unused-value',
};
const obj: MCPOptions = {
command: 'node',
args: ['server.js'],
env: {
API_KEY: '${TEST_API_KEY}',
},
};
const result = processMCPEnv(obj, user, customUserVars);
expect('env' in result && result.env).toEqual({
API_KEY: 'test-api-key-value',
});
});
it('should handle placeholders with no matching customUserVars (falling back to user/system vars)', () => {
const user = createTestUser({ email: 'user-provided-email@example.com' });
// No customUserVars provided or customUserVars is empty
const customUserVars = {};
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com/api',
headers: {
'User-Email-Header': '{{LIBRECHAT_USER_EMAIL}}', // Should use user.email
'System-Key-Header': '${TEST_API_KEY}', // Should use process.env.TEST_API_KEY
'Non-Existent-Custom': '{{NON_EXISTENT_CUSTOM_VAR}}', // Should remain as placeholder
},
};
const result = processMCPEnv(obj, user, customUserVars);
expect('headers' in result && result.headers).toEqual({
'User-Email-Header': 'user-provided-email@example.com',
'System-Key-Header': 'test-api-key-value',
'Non-Existent-Custom': '{{NON_EXISTENT_CUSTOM_VAR}}',
});
});
it('should correctly process a mix of all variable types', () => {
const user = createTestUser({ id: 'userXYZ', username: 'john.doe' });
const customUserVars = {
CUSTOM_ENDPOINT_ID: 'ep123',
ANOTHER_CUSTOM: 'another_val',
};
const obj = {
type: 'streamable-http' as const,
url: 'https://{{CUSTOM_ENDPOINT_ID}}.example.com/users/{{LIBRECHAT_USER_USERNAME}}',
headers: {
'X-Auth-Token': '{{CUSTOM_TOKEN_FROM_USER_SETTINGS}}', // Assuming this would be a custom var
'X-User-ID': '{{LIBRECHAT_USER_ID}}',
'X-System-Test-Key': '${TEST_API_KEY}', // Using existing env var from beforeEach
},
env: {
PROCESS_MODE: '{{PROCESS_MODE_CUSTOM}}', // Another custom var
USER_HOME_DIR: '/home/{{LIBRECHAT_USER_USERNAME}}',
SYSTEM_PATH: '${PATH}', // Example of a system env var
},
};
// Simulate customUserVars that would be passed, including those for headers and env
const allCustomVarsForCall = {
...customUserVars,
CUSTOM_TOKEN_FROM_USER_SETTINGS: 'secretToken123!',
PROCESS_MODE_CUSTOM: 'production',
};
// Cast obj to MCPOptions when calling processMCPEnv.
// This acknowledges the object might not strictly conform to one schema in the union,
// but we are testing the function's ability to handle these properties if present.
const result = processMCPEnv(obj as MCPOptions, user, allCustomVarsForCall);
expect('url' in result && result.url).toBe('https://ep123.example.com/users/john.doe');
expect('headers' in result && result.headers).toEqual({
'X-Auth-Token': 'secretToken123!',
'X-User-ID': 'userXYZ',
'X-System-Test-Key': 'test-api-key-value', // Expecting value of TEST_API_KEY
});
expect('env' in result && result.env).toEqual({
PROCESS_MODE: 'production',
USER_HOME_DIR: '/home/john.doe',
SYSTEM_PATH: process.env.PATH, // Actual value of PATH from the test environment
});
});
});
});

View file

@ -70,8 +70,6 @@ export const revokeUserKey = (name: string) => `${keysEndpoint}/${name}`;
export const revokeAllUserKeys = () => `${keysEndpoint}?all=true`;
export const abortRequest = (endpoint: string) => `/api/ask/${endpoint}/abort`;
export const conversationsRoot = '/api/convos';
export const conversations = (params: q.ConversationListParams) => {

View file

@ -482,6 +482,12 @@ const termsOfServiceSchema = z.object({
export type TTermsOfService = z.infer<typeof termsOfServiceSchema>;
const mcpServersSchema = z.object({
placeholder: z.string().optional(),
});
export type TMcpServersConfig = z.infer<typeof mcpServersSchema>;
export const intefaceSchema = z
.object({
privacyPolicy: z
@ -492,6 +498,7 @@ export const intefaceSchema = z
.optional(),
termsOfService: termsOfServiceSchema.optional(),
customWelcome: z.string().optional(),
mcpServers: mcpServersSchema.optional(),
endpointsMenu: z.boolean().optional(),
modelSelect: z.boolean().optional(),
parameters: z.boolean().optional(),
@ -503,6 +510,7 @@ export const intefaceSchema = z
prompts: z.boolean().optional(),
agents: z.boolean().optional(),
temporaryChat: z.boolean().optional(),
temporaryChatRetention: z.number().min(1).max(8760).optional(),
runCode: z.boolean().optional(),
webSearch: z.boolean().optional(),
})
@ -600,12 +608,14 @@ export type TStartupConfig = {
>;
}
>;
mcpPlaceholder?: string;
};
export enum OCRStrategy {
MISTRAL_OCR = 'mistral_ocr',
CUSTOM_OCR = 'custom_ocr',
AZURE_MISTRAL_OCR = 'azure_mistral_ocr',
VERTEXAI_MISTRAL_OCR = 'vertexai_mistral_ocr',
}
export enum SearchCategories {
@ -637,6 +647,8 @@ export enum SafeSearchTypes {
export const webSearchSchema = z.object({
serperApiKey: z.string().optional().default('${SERPER_API_KEY}'),
searxngInstanceUrl: z.string().optional().default('${SEARXNG_INSTANCE_URL}'),
searxngApiKey: z.string().optional().default('${SEARXNG_API_KEY}'),
firecrawlApiKey: z.string().optional().default('${FIRECRAWL_API_KEY}'),
firecrawlApiUrl: z.string().optional().default('${FIRECRAWL_API_URL}'),
jinaApiKey: z.string().optional().default('${JINA_API_KEY}'),
@ -940,19 +952,11 @@ export const initialModelsConfig: TModelsConfig = {
[EModelEndpoint.bedrock]: defaultModels[EModelEndpoint.bedrock],
};
export const EndpointURLs: { [key in EModelEndpoint]: string } = {
[EModelEndpoint.openAI]: `/api/ask/${EModelEndpoint.openAI}`,
[EModelEndpoint.google]: `/api/ask/${EModelEndpoint.google}`,
[EModelEndpoint.custom]: `/api/ask/${EModelEndpoint.custom}`,
[EModelEndpoint.anthropic]: `/api/ask/${EModelEndpoint.anthropic}`,
[EModelEndpoint.gptPlugins]: `/api/ask/${EModelEndpoint.gptPlugins}`,
[EModelEndpoint.azureOpenAI]: `/api/ask/${EModelEndpoint.azureOpenAI}`,
[EModelEndpoint.chatGPTBrowser]: `/api/ask/${EModelEndpoint.chatGPTBrowser}`,
[EModelEndpoint.azureAssistants]: '/api/assistants/v1/chat',
export const EndpointURLs = {
[EModelEndpoint.assistants]: '/api/assistants/v2/chat',
[EModelEndpoint.azureAssistants]: '/api/assistants/v1/chat',
[EModelEndpoint.agents]: `/api/${EModelEndpoint.agents}/chat`,
[EModelEndpoint.bedrock]: `/api/${EModelEndpoint.bedrock}/chat`,
};
} as const;
export const modularEndpoints = new Set<EModelEndpoint | string>([
EModelEndpoint.gptPlugins,
@ -1255,6 +1259,10 @@ export enum ErrorTypes {
* Google provider returned an error
*/
GOOGLE_ERROR = 'google_error',
/**
* Google provider does not allow custom tools with built-in tools
*/
GOOGLE_TOOL_CONFLICT = 'google_tool_conflict',
/**
* Invalid Agent Provider (excluded by Admin)
*/
@ -1377,7 +1385,7 @@ export enum TTSProviders {
/** Enum for app-wide constants */
export enum Constants {
/** Key for the app's version. */
VERSION = 'v0.7.8',
VERSION = 'v0.7.9-rc1',
/** Key for the Custom Config's version (librechat.yaml). */
CONFIG_VERSION = '1.2.8',
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
@ -1451,10 +1459,20 @@ export enum LocalStorageKeys {
LAST_CODE_TOGGLE_ = 'LAST_CODE_TOGGLE_',
/** Last checked toggle for Web Search per conversation ID */
LAST_WEB_SEARCH_TOGGLE_ = 'LAST_WEB_SEARCH_TOGGLE_',
/** Last checked toggle for File Search per conversation ID */
LAST_FILE_SEARCH_TOGGLE_ = 'LAST_FILE_SEARCH_TOGGLE_',
/** Last checked toggle for Artifacts per conversation ID */
LAST_ARTIFACTS_TOGGLE_ = 'LAST_ARTIFACTS_TOGGLE_',
/** Key for the last selected agent provider */
LAST_AGENT_PROVIDER = 'lastAgentProvider',
/** Key for the last selected agent model */
LAST_AGENT_MODEL = 'lastAgentModel',
/** Pin state for MCP tools per conversation ID */
PIN_MCP_ = 'PIN_MCP_',
/** Pin state for Web Search per conversation ID */
PIN_WEB_SEARCH_ = 'PIN_WEB_SEARCH_',
/** Pin state for Code Interpreter per conversation ID */
PIN_CODE_INTERPRETER_ = 'PIN_CODE_INTERPRETER_',
}
export enum ForkOptions {

View file

@ -11,32 +11,31 @@ export default function createPayload(submission: t.TSubmission) {
isContinued,
isTemporary,
ephemeralAgent,
editedContent,
} = submission;
const { conversationId } = s.tConvoUpdateSchema.parse(conversation);
const { endpoint: _e, endpointType } = endpointOption as {
endpoint: s.EModelEndpoint;
endpointType?: s.EModelEndpoint;
};
const endpoint = _e as s.EModelEndpoint;
let server = EndpointURLs[endpointType ?? endpoint];
const isEphemeral = s.isEphemeralAgent(endpoint, ephemeralAgent);
if (isEdited && s.isAssistantsEndpoint(endpoint)) {
server += '/modify';
} else if (isEdited) {
server = server.replace('/ask/', '/edit/');
} else if (isEphemeral) {
server = `${EndpointURLs[s.EModelEndpoint.agents]}/${endpoint}`;
const endpoint = _e as s.EModelEndpoint;
let server = `${EndpointURLs[s.EModelEndpoint.agents]}/${endpoint}`;
if (s.isAssistantsEndpoint(endpoint)) {
server =
EndpointURLs[(endpointType ?? endpoint) as 'assistants' | 'azureAssistants'] +
(isEdited ? '/modify' : '');
}
const payload: t.TPayload = {
...userMessage,
...endpointOption,
endpoint,
ephemeralAgent: isEphemeral ? ephemeralAgent : undefined,
ephemeralAgent: s.isAssistantsEndpoint(endpoint) ? undefined : ephemeralAgent,
isContinued: !!(isEdited && isContinued),
conversationId,
isTemporary,
editedContent,
};
return { server, payload };

View file

@ -11,14 +11,6 @@ import request from './request';
import * as s from './schemas';
import * as r from './roles';
export function abortRequestWithMessage(
endpoint: string,
abortKey: string,
message: string,
): Promise<void> {
return request.post(endpoints.abortRequest(endpoint), { arg: { abortKey, message } });
}
export function revokeUserKey(name: string): Promise<unknown> {
return request.delete(endpoints.revokeUserKey(name));
}

View file

@ -192,6 +192,12 @@ export const fileConfig = {
},
serverFileSizeLimit: defaultSizeLimit,
avatarSizeLimit: mbToBytes(2),
clientImageResize: {
enabled: false,
maxWidth: 1900,
maxHeight: 1900,
quality: 0.92,
},
checkType: function (fileType: string, supportedTypes: RegExp[] = supportedMimeTypes) {
return supportedTypes.some((regex) => regex.test(fileType));
},
@ -232,6 +238,14 @@ export const fileConfigSchema = z.object({
px: z.number().min(0).optional(),
})
.optional(),
clientImageResize: z
.object({
enabled: z.boolean().optional(),
maxWidth: z.number().min(0).optional(),
maxHeight: z.number().min(0).optional(),
quality: z.number().min(0).max(1).optional(),
})
.optional(),
});
/** Helper function to safely convert string patterns to RegExp objects */
@ -260,6 +274,14 @@ export function mergeFileConfig(dynamic: z.infer<typeof fileConfigSchema> | unde
mergedConfig.avatarSizeLimit = mbToBytes(dynamic.avatarSizeLimit);
}
// Merge clientImageResize configuration
if (dynamic.clientImageResize !== undefined) {
mergedConfig.clientImageResize = {
...mergedConfig.clientImageResize,
...dynamic.clientImageResize,
};
}
if (!dynamic.endpoints) {
return mergedConfig;
}

View file

@ -467,7 +467,11 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
}
/* Default value checks */
if (setting.type === SettingTypes.Number && isNaN(setting.default as number) && setting.default != null) {
if (
setting.type === SettingTypes.Number &&
isNaN(setting.default as number) &&
setting.default != null
) {
errors.push({
code: ZodIssueCode.custom,
message: `Invalid default value for setting ${setting.key}. Must be a number.`,
@ -475,7 +479,11 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
});
}
if (setting.type === SettingTypes.Boolean && typeof setting.default !== 'boolean' && setting.default != null) {
if (
setting.type === SettingTypes.Boolean &&
typeof setting.default !== 'boolean' &&
setting.default != null
) {
errors.push({
code: ZodIssueCode.custom,
message: `Invalid default value for setting ${setting.key}. Must be a boolean.`,
@ -485,7 +493,8 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
if (
(setting.type === SettingTypes.String || setting.type === SettingTypes.Enum) &&
typeof setting.default !== 'string' && setting.default != null
typeof setting.default !== 'string' &&
setting.default != null
) {
errors.push({
code: ZodIssueCode.custom,
@ -520,6 +529,19 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
path: ['default'],
});
}
// Validate enumMappings
if (setting.enumMappings && setting.type === SettingTypes.Enum && setting.options) {
for (const option of setting.options) {
if (!(option in setting.enumMappings)) {
errors.push({
code: ZodIssueCode.custom,
message: `Missing enumMapping for option "${option}" in setting ${setting.key}.`,
path: ['enumMappings'],
});
}
}
}
}
if (errors.length > 0) {

View file

@ -1,7 +1,6 @@
import { z } from 'zod';
import type { TUser } from './types';
import { extractEnvVariable } from './utils';
import { TokenExchangeMethodEnum } from './types/agents';
import { extractEnvVariable } from './utils';
const BaseOptionsSchema = z.object({
iconPath: z.string().optional(),
@ -153,130 +152,3 @@ export const MCPOptionsSchema = z.union([
export const MCPServersSchema = z.record(z.string(), MCPOptionsSchema);
export type MCPOptions = z.infer<typeof MCPOptionsSchema>;
/**
* List of allowed user fields that can be used in MCP environment variables.
* These are non-sensitive string/boolean fields from the IUser interface.
*/
const ALLOWED_USER_FIELDS = [
'name',
'username',
'email',
'provider',
'role',
'googleId',
'facebookId',
'openidId',
'samlId',
'ldapId',
'githubId',
'discordId',
'appleId',
'emailVerified',
'twoFactorEnabled',
'termsAccepted',
] as const;
/**
* Processes a string value to replace user field placeholders
* @param value - The string value to process
* @param user - The user object
* @returns The processed string with placeholders replaced
*/
function processUserPlaceholders(value: string, user?: TUser): string {
if (!user || typeof value !== 'string') {
return value;
}
for (const field of ALLOWED_USER_FIELDS) {
const placeholder = `{{LIBRECHAT_USER_${field.toUpperCase()}}}`;
if (value.includes(placeholder)) {
const fieldValue = user[field as keyof TUser];
const replacementValue = fieldValue != null ? String(fieldValue) : '';
value = value.replace(new RegExp(placeholder, 'g'), replacementValue);
}
}
return value;
}
function processSingleValue({
originalValue,
customUserVars,
user,
}: {
originalValue: string;
customUserVars?: Record<string, string>;
user?: TUser;
}): string {
let value = originalValue;
// 1. Replace custom user variables
if (customUserVars) {
for (const [varName, varVal] of Object.entries(customUserVars)) {
/** Escaped varName for use in regex to avoid issues with special characters */
const escapedVarName = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const placeholderRegex = new RegExp(`\\{\\{${escapedVarName}\\}\\}`, 'g');
value = value.replace(placeholderRegex, varVal);
}
}
// 2.A. Special handling for LIBRECHAT_USER_ID placeholder
// This ensures {{LIBRECHAT_USER_ID}} is replaced only if user.id is available.
// If user.id is null/undefined, the placeholder remains
if (user && user.id != null && value.includes('{{LIBRECHAT_USER_ID}}')) {
value = value.replace(/\{\{LIBRECHAT_USER_ID\}\}/g, String(user.id));
}
// 2.B. Replace other standard user field placeholders (e.g., {{LIBRECHAT_USER_EMAIL}})
value = processUserPlaceholders(value, user);
// 3. Replace system environment variables
value = extractEnvVariable(value);
return value;
}
/**
* Recursively processes an object to replace environment variables in string values
* @param obj - The object to process
* @param user - The user object containing all user fields
* @param customUserVars - vars that user set in settings
* @returns - The processed object with environment variables replaced
*/
export function processMCPEnv(
obj: Readonly<MCPOptions>,
user?: TUser,
customUserVars?: Record<string, string>,
): MCPOptions {
if (obj === null || obj === undefined) {
return obj;
}
const newObj: MCPOptions = structuredClone(obj);
if ('env' in newObj && newObj.env) {
const processedEnv: Record<string, string> = {};
for (const [key, originalValue] of Object.entries(newObj.env)) {
processedEnv[key] = processSingleValue({ originalValue, customUserVars, user });
}
newObj.env = processedEnv;
}
// Process headers if they exist (for WebSocket, SSE, StreamableHTTP types)
// Note: `env` and `headers` are on different branches of the MCPOptions union type.
if ('headers' in newObj && newObj.headers) {
const processedHeaders: Record<string, string> = {};
for (const [key, originalValue] of Object.entries(newObj.headers)) {
processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user });
}
newObj.headers = processedHeaders;
}
// Process URL if it exists (for WebSocket, SSE, StreamableHTTP types)
if ('url' in newObj && newObj.url) {
newObj.url = processSingleValue({ originalValue: newObj.url, customUserVars, user });
}
return newObj;
}

View file

@ -4,6 +4,7 @@ import {
openAISettings,
googleSettings,
ReasoningEffort,
ReasoningSummary,
BedrockProviders,
anthropicSettings,
} from './types';
@ -71,6 +72,11 @@ const baseDefinitions: Record<string, SettingDefinition> = {
default: ImageDetail.auto,
component: 'slider',
options: [ImageDetail.low, ImageDetail.auto, ImageDetail.high],
enumMappings: {
[ImageDetail.low]: 'com_ui_low',
[ImageDetail.auto]: 'com_ui_auto',
[ImageDetail.high]: 'com_ui_high',
},
optionType: 'conversation',
columnSpan: 2,
},
@ -83,7 +89,7 @@ const createDefinition = (
return { ...base, ...overrides } as SettingDefinition;
};
const librechat: Record<string, SettingDefinition> = {
export const librechat = {
modelLabel: {
key: 'modelLabel',
label: 'com_endpoint_custom_name',
@ -94,7 +100,7 @@ const librechat: Record<string, SettingDefinition> = {
placeholder: 'com_endpoint_openai_custom_name_placeholder',
placeholderCode: true,
optionType: 'conversation',
},
} as const,
maxContextTokens: {
key: 'maxContextTokens',
label: 'com_endpoint_context_tokens',
@ -107,7 +113,7 @@ const librechat: Record<string, SettingDefinition> = {
descriptionCode: true,
optionType: 'model',
columnSpan: 2,
},
} as const,
resendFiles: {
key: 'resendFiles',
label: 'com_endpoint_plug_resend_files',
@ -120,7 +126,7 @@ const librechat: Record<string, SettingDefinition> = {
optionType: 'conversation',
showDefault: false,
columnSpan: 2,
},
} as const,
promptPrefix: {
key: 'promptPrefix',
label: 'com_endpoint_prompt_prefix',
@ -131,7 +137,7 @@ const librechat: Record<string, SettingDefinition> = {
placeholder: 'com_endpoint_openai_prompt_prefix_placeholder',
placeholderCode: true,
optionType: 'model',
},
} as const,
};
const openAIParams: Record<string, SettingDefinition> = {
@ -211,9 +217,70 @@ const openAIParams: Record<string, SettingDefinition> = {
description: 'com_endpoint_openai_reasoning_effort',
descriptionCode: true,
type: 'enum',
default: ReasoningEffort.medium,
default: ReasoningEffort.none,
component: 'slider',
options: [ReasoningEffort.low, ReasoningEffort.medium, ReasoningEffort.high],
options: [
ReasoningEffort.none,
ReasoningEffort.low,
ReasoningEffort.medium,
ReasoningEffort.high,
],
enumMappings: {
[ReasoningEffort.none]: 'com_ui_none',
[ReasoningEffort.low]: 'com_ui_low',
[ReasoningEffort.medium]: 'com_ui_medium',
[ReasoningEffort.high]: 'com_ui_high',
},
optionType: 'model',
columnSpan: 4,
},
useResponsesApi: {
key: 'useResponsesApi',
label: 'com_endpoint_use_responses_api',
labelCode: true,
description: 'com_endpoint_openai_use_responses_api',
descriptionCode: true,
type: 'boolean',
default: false,
component: 'switch',
optionType: 'model',
showDefault: false,
columnSpan: 2,
},
web_search: {
key: 'web_search',
label: 'com_ui_web_search',
labelCode: true,
description: 'com_endpoint_openai_use_web_search',
descriptionCode: true,
type: 'boolean',
default: false,
component: 'switch',
optionType: 'model',
showDefault: false,
columnSpan: 2,
},
reasoning_summary: {
key: 'reasoning_summary',
label: 'com_endpoint_reasoning_summary',
labelCode: true,
description: 'com_endpoint_openai_reasoning_summary',
descriptionCode: true,
type: 'enum',
default: ReasoningSummary.none,
component: 'slider',
options: [
ReasoningSummary.none,
ReasoningSummary.auto,
ReasoningSummary.concise,
ReasoningSummary.detailed,
],
enumMappings: {
[ReasoningSummary.none]: 'com_ui_none',
[ReasoningSummary.auto]: 'com_ui_auto',
[ReasoningSummary.concise]: 'com_ui_concise',
[ReasoningSummary.detailed]: 'com_ui_detailed',
},
optionType: 'model',
columnSpan: 4,
},
@ -314,6 +381,19 @@ const anthropic: Record<string, SettingDefinition> = {
optionType: 'conversation',
columnSpan: 2,
},
web_search: {
key: 'web_search',
label: 'com_ui_web_search',
labelCode: true,
description: 'com_endpoint_anthropic_use_web_search',
descriptionCode: true,
type: 'boolean',
default: anthropicSettings.web_search.default,
component: 'switch',
optionType: 'conversation',
showDefault: false,
columnSpan: 2,
},
};
const bedrock: Record<string, SettingDefinition> = {
@ -347,7 +427,9 @@ const bedrock: Record<string, SettingDefinition> = {
labelCode: true,
type: 'number',
component: 'input',
placeholder: 'com_endpoint_anthropic_maxoutputtokens',
description: 'com_endpoint_anthropic_maxoutputtokens',
descriptionCode: true,
placeholder: 'com_nav_theme_system',
placeholderCode: true,
optionType: 'model',
columnSpan: 2,
@ -450,6 +532,50 @@ const google: Record<string, SettingDefinition> = {
optionType: 'model',
columnSpan: 2,
},
thinking: {
key: 'thinking',
label: 'com_endpoint_thinking',
labelCode: true,
description: 'com_endpoint_google_thinking',
descriptionCode: true,
type: 'boolean',
default: googleSettings.thinking.default,
component: 'switch',
optionType: 'conversation',
showDefault: false,
columnSpan: 2,
},
thinkingBudget: {
key: 'thinkingBudget',
label: 'com_endpoint_thinking_budget',
labelCode: true,
description: 'com_endpoint_google_thinking_budget',
descriptionCode: true,
placeholder: 'com_ui_auto',
placeholderCode: true,
type: 'number',
component: 'input',
range: {
min: googleSettings.thinkingBudget.min,
max: googleSettings.thinkingBudget.max,
step: googleSettings.thinkingBudget.step,
},
optionType: 'conversation',
columnSpan: 2,
},
web_search: {
key: 'web_search',
label: 'com_endpoint_use_search_grounding',
labelCode: true,
description: 'com_endpoint_google_use_search_grounding',
descriptionCode: true,
type: 'boolean',
default: false,
component: 'switch',
optionType: 'model',
showDefault: false,
columnSpan: 2,
},
};
const googleConfig: SettingsConfiguration = [
@ -461,6 +587,9 @@ const googleConfig: SettingsConfiguration = [
google.topP,
google.topK,
librechat.resendFiles,
google.thinking,
google.thinkingBudget,
google.web_search,
];
const googleCol1: SettingsConfiguration = [
@ -476,6 +605,9 @@ const googleCol2: SettingsConfiguration = [
google.topP,
google.topK,
librechat.resendFiles,
google.thinking,
google.thinkingBudget,
google.web_search,
];
const openAI: SettingsConfiguration = [
@ -490,7 +622,10 @@ const openAI: SettingsConfiguration = [
baseDefinitions.stop,
librechat.resendFiles,
baseDefinitions.imageDetail,
openAIParams.web_search,
openAIParams.reasoning_effort,
openAIParams.useResponsesApi,
openAIParams.reasoning_summary,
];
const openAICol1: SettingsConfiguration = [
@ -507,9 +642,12 @@ const openAICol2: SettingsConfiguration = [
openAIParams.frequency_penalty,
openAIParams.presence_penalty,
baseDefinitions.stop,
openAIParams.reasoning_effort,
librechat.resendFiles,
baseDefinitions.imageDetail,
openAIParams.reasoning_effort,
openAIParams.reasoning_summary,
openAIParams.useResponsesApi,
openAIParams.web_search,
];
const anthropicConfig: SettingsConfiguration = [
@ -524,6 +662,7 @@ const anthropicConfig: SettingsConfiguration = [
anthropic.promptCache,
anthropic.thinking,
anthropic.thinkingBudget,
anthropic.web_search,
];
const anthropicCol1: SettingsConfiguration = [
@ -542,6 +681,7 @@ const anthropicCol2: SettingsConfiguration = [
anthropic.promptCache,
anthropic.thinking,
anthropic.thinkingBudget,
anthropic.web_search,
];
const bedrockAnthropic: SettingsConfiguration = [

View file

@ -122,19 +122,6 @@ export function errorsToString(errors: ZodIssue[]) {
.join(' ');
}
/** Resolves header values to env variables if detected */
export function resolveHeaders(headers: Record<string, string> | undefined) {
const resolvedHeaders = { ...(headers ?? {}) };
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
Object.keys(headers).forEach((key) => {
resolvedHeaders[key] = extractEnvVariable(headers[key]);
});
}
return resolvedHeaders;
}
export function getFirstDefinedValue(possibleValues: string[]) {
let returnValue;
for (const value of possibleValues) {
@ -275,15 +262,11 @@ export const getResponseSender = (endpointOption: t.TEndpointOption): string =>
if (endpoint === EModelEndpoint.google) {
if (modelLabel) {
return modelLabel;
} else if (model && (model.includes('gemini') || model.includes('learnlm'))) {
return 'Gemini';
} else if (model?.toLowerCase().includes('gemma') === true) {
return 'Gemma';
} else if (model && model.includes('code')) {
return 'Codey';
}
return 'PaLM2';
return 'Gemini';
}
if (endpoint === EModelEndpoint.custom || endpointType === EModelEndpoint.custom) {

View file

@ -12,23 +12,6 @@ import { QueryKeys } from '../keys';
import * as s from '../schemas';
import * as t from '../types';
export const useAbortRequestWithMessage = (): UseMutationResult<
void,
Error,
{ endpoint: string; abortKey: string; message: string }
> => {
const queryClient = useQueryClient();
return useMutation(
({ endpoint, abortKey, message }) =>
dataService.abortRequestWithMessage(endpoint, abortKey, message),
{
onSuccess: () => {
queryClient.invalidateQueries([QueryKeys.balance]);
},
},
);
};
export const useGetSharedMessages = (
shareId: string,
config?: UseQueryOptions<t.TSharedMessagesResponse>,

View file

@ -3,7 +3,6 @@ import { Tools } from './types/assistants';
import type { TMessageContentParts, FunctionTool, FunctionToolCall } from './types/assistants';
import { TFeedback, feedbackSchema } from './feedback';
import type { SearchResultData } from './types/web';
import type { TEphemeralAgent } from './types';
import type { TFile } from './types/files';
export const isUUID = z.string().uuid();
@ -91,22 +90,6 @@ export const isAgentsEndpoint = (_endpoint?: EModelEndpoint.agents | null | stri
return endpoint === EModelEndpoint.agents;
};
export const isEphemeralAgent = (
endpoint?: EModelEndpoint.agents | null | string,
ephemeralAgent?: TEphemeralAgent | null,
) => {
if (!ephemeralAgent) {
return false;
}
if (isAgentsEndpoint(endpoint)) {
return false;
}
const hasMCPSelected = (ephemeralAgent?.mcp?.length ?? 0) > 0;
const hasCodeSelected = (ephemeralAgent?.execute_code ?? false) === true;
const hasSearchSelected = (ephemeralAgent?.web_search ?? false) === true;
return hasMCPSelected || hasCodeSelected || hasSearchSelected;
};
export const isParamEndpoint = (
endpoint: EModelEndpoint | string,
endpointType?: EModelEndpoint | string,
@ -129,11 +112,19 @@ export enum ImageDetail {
}
export enum ReasoningEffort {
none = '',
low = 'low',
medium = 'medium',
high = 'high',
}
export enum ReasoningSummary {
none = '',
auto = 'auto',
concise = 'concise',
detailed = 'detailed',
}
export const imageDetailNumeric = {
[ImageDetail.low]: 0,
[ImageDetail.auto]: 1,
@ -148,6 +139,7 @@ export const imageDetailValue = {
export const eImageDetailSchema = z.nativeEnum(ImageDetail);
export const eReasoningEffortSchema = z.nativeEnum(ReasoningEffort);
export const eReasoningSummarySchema = z.nativeEnum(ReasoningSummary);
export const defaultAssistantFormValues = {
assistant: '',
@ -272,6 +264,18 @@ export const googleSettings = {
step: 1 as const,
default: 40 as const,
},
thinking: {
default: true as const,
},
thinkingBudget: {
min: -1 as const,
max: 32768 as const,
step: 1 as const,
/** `-1` = Dynamic Thinking, meaning the model will adjust
* the budget based on the complexity of the request.
*/
default: -1 as const,
},
};
const ANTHROPIC_MAX_OUTPUT = 128000 as const;
@ -348,6 +352,9 @@ export const anthropicSettings = {
default: LEGACY_ANTHROPIC_MAX_OUTPUT,
},
},
web_search: {
default: false as const,
},
};
export const agentsSettings = {
@ -417,7 +424,7 @@ export type TPluginAuthConfig = z.infer<typeof tPluginAuthConfigSchema>;
export const tPluginSchema = z.object({
name: z.string(),
pluginKey: z.string(),
description: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
authConfig: z.array(tPluginAuthConfigSchema).optional(),
authenticated: z.boolean().optional(),
@ -499,6 +506,7 @@ export const tMessageSchema = z.object({
title: z.string().nullable().or(z.literal('New Chat')).default('New Chat'),
sender: z.string().optional(),
text: z.string(),
/** @deprecated */
generation: z.string().nullable().optional(),
isCreatedByUser: z.boolean(),
error: z.boolean().optional(),
@ -624,8 +632,13 @@ export const tConversationSchema = z.object({
file_ids: z.array(z.string()).optional(),
/* vision */
imageDetail: eImageDetailSchema.optional(),
/* OpenAI: o1 only */
reasoning_effort: eReasoningEffortSchema.optional(),
/* OpenAI: Reasoning models only */
reasoning_effort: eReasoningEffortSchema.optional().nullable(),
reasoning_summary: eReasoningSummarySchema.optional().nullable(),
/* OpenAI: use Responses API */
useResponsesApi: z.boolean().optional(),
/* OpenAI Responses API / Anthropic API / Google API */
web_search: z.boolean().optional(),
/* assistant */
assistant_id: z.string().optional(),
/* agents */
@ -722,6 +735,14 @@ export const tQueryParamsSchema = tConversationSchema
top_p: true,
/** @endpoints openAI, custom, azureOpenAI */
max_tokens: true,
/** @endpoints openAI, custom, azureOpenAI */
reasoning_effort: true,
/** @endpoints openAI, custom, azureOpenAI */
reasoning_summary: true,
/** @endpoints openAI, custom, azureOpenAI */
useResponsesApi: true,
/** @endpoints openAI, anthropic, google */
web_search: true,
/** @endpoints google, anthropic, bedrock */
topP: true,
/** @endpoints google, anthropic */
@ -802,6 +823,9 @@ export const googleBaseSchema = tConversationSchema.pick({
artifacts: true,
topP: true,
topK: true,
thinking: true,
thinkingBudget: true,
web_search: true,
iconURL: true,
greeting: true,
spec: true,
@ -827,6 +851,13 @@ export const googleGenConfigSchema = z
presencePenalty: coerceNumber.optional(),
frequencyPenalty: coerceNumber.optional(),
stopSequences: z.array(z.string()).optional(),
thinkingConfig: z
.object({
includeThoughts: z.boolean().optional(),
thinkingBudget: coerceNumber.optional(),
})
.optional(),
web_search: z.boolean().optional(),
})
.strip()
.optional();
@ -1041,10 +1072,13 @@ export const openAIBaseSchema = tConversationSchema.pick({
maxContextTokens: true,
max_tokens: true,
reasoning_effort: true,
reasoning_summary: true,
useResponsesApi: true,
web_search: true,
});
export const openAISchema = openAIBaseSchema
.transform((obj: Partial<TConversation>) => removeNullishValues(obj))
.transform((obj: Partial<TConversation>) => removeNullishValues(obj, true))
.catch(() => ({}));
export const compactGoogleSchema = googleBaseSchema
@ -1084,6 +1118,7 @@ export const anthropicBaseSchema = tConversationSchema.pick({
greeting: true,
spec: true,
maxContextTokens: true,
web_search: true,
});
export const anthropicSchema = anthropicBaseSchema

View file

@ -98,6 +98,7 @@ export type TEndpointOption = Pick<
export type TEphemeralAgent = {
mcp?: string[];
web_search?: boolean;
file_search?: boolean;
execute_code?: boolean;
};
@ -108,10 +109,14 @@ export type TPayload = Partial<TMessage> &
messages?: TMessages;
isTemporary: boolean;
ephemeralAgent?: TEphemeralAgent | null;
editedContent?: {
index: number;
text: string;
type: 'text' | 'think';
} | null;
};
export type TSubmission = {
artifacts?: string;
plugin?: TResPlugin;
plugins?: TResPlugin[];
userMessage: TMessage;
@ -126,6 +131,11 @@ export type TSubmission = {
endpointOption: TEndpointOption;
clientTimestamp?: string;
ephemeralAgent?: TEphemeralAgent | null;
editedContent?: {
index: number;
text: string;
type: 'text' | 'think';
} | null;
};
export type EventSubmission = Omit<TSubmission, 'initialResponse'> & { initialResponse: TMessage };
@ -133,7 +143,7 @@ export type EventSubmission = Omit<TSubmission, 'initialResponse'> & { initialRe
export type TPluginAction = {
pluginKey: string;
action: 'install' | 'uninstall';
auth?: Partial<Record<string, string>>;
auth?: Partial<Record<string, string>> | null;
isEntityTool?: boolean;
};
@ -143,7 +153,7 @@ export type TUpdateUserPlugins = {
isEntityTool?: boolean;
pluginKey: string;
action: string;
auth?: Partial<Record<string, string | null>>;
auth?: Partial<Record<string, string | null>> | null;
};
// TODO `label` needs to be changed to the proper `TranslationKeys`

View file

@ -11,6 +11,7 @@ export enum FileSources {
execute_code = 'execute_code',
mistral_ocr = 'mistral_ocr',
azure_mistral_ocr = 'azure_mistral_ocr',
vertexai_mistral_ocr = 'vertexai_mistral_ocr',
text = 'text',
}
@ -48,6 +49,12 @@ export type FileConfig = {
};
serverFileSizeLimit?: number;
avatarSizeLimit?: number;
clientImageResize?: {
enabled?: boolean;
maxWidth?: number;
maxHeight?: number;
quality?: number;
};
checkType?: (fileType: string, supportedTypes: RegExp[]) => boolean;
};

View file

@ -13,6 +13,8 @@ export function loadWebSearchConfig(
config: TCustomConfig['webSearch'],
): TCustomConfig['webSearch'] {
const serperApiKey = config?.serperApiKey ?? '${SERPER_API_KEY}';
const searxngInstanceUrl = config?.searxngInstanceUrl ?? '${SEARXNG_INSTANCE_URL}';
const searxngApiKey = config?.searxngApiKey ?? '${SEARXNG_API_KEY}';
const firecrawlApiKey = config?.firecrawlApiKey ?? '${FIRECRAWL_API_KEY}';
const firecrawlApiUrl = config?.firecrawlApiUrl ?? '${FIRECRAWL_API_URL}';
const jinaApiKey = config?.jinaApiKey ?? '${JINA_API_KEY}';
@ -25,6 +27,8 @@ export function loadWebSearchConfig(
jinaApiKey,
cohereApiKey,
serperApiKey,
searxngInstanceUrl,
searxngApiKey,
firecrawlApiKey,
firecrawlApiUrl,
};
@ -32,6 +36,8 @@ export function loadWebSearchConfig(
export type TWebSearchKeys =
| 'serperApiKey'
| 'searxngInstanceUrl'
| 'searxngApiKey'
| 'firecrawlApiKey'
| 'firecrawlApiUrl'
| 'jinaApiKey'
@ -47,6 +53,11 @@ export const webSearchAuth = {
serper: {
serperApiKey: 1 as const,
},
searxng: {
searxngInstanceUrl: 1 as const,
/** Optional (0) */
searxngApiKey: 0 as const,
},
},
scrapers: {
firecrawl: {