Merge branch 'dev' into feat/multi-lang-Terms-of-service

This commit is contained in:
Ruben Talstra 2025-05-29 17:06:50 +02:00 committed by GitHub
commit 126b1fe412
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
323 changed files with 20207 additions and 4039 deletions

View file

@ -1,6 +1,6 @@
{
"name": "librechat-data-provider",
"version": "0.7.82",
"version": "0.7.86",
"description": "data services for librechat apps",
"main": "dist/index.js",
"module": "dist/index.es.js",
@ -48,6 +48,7 @@
"@babel/preset-env": "^7.21.5",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.21.0",
"@langchain/core": "^0.3.57",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^25.0.2",
"@rollup/plugin-json": "^6.1.0",
@ -58,6 +59,7 @@
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.3.0",
"@types/react": "^18.2.18",
"@types/winston": "^2.4.4",
"jest": "^29.5.0",
"jest-junit": "^16.0.0",
"openai": "^4.76.3",

View file

@ -0,0 +1,114 @@
import { bedrockInputParser } from '../src/bedrock';
import type { BedrockConverseInput } from '../src/bedrock';
describe('bedrockInputParser', () => {
describe('Model Matching for Reasoning Configuration', () => {
test('should match anthropic.claude-3-7-sonnet model', () => {
const input = {
model: 'anthropic.claude-3-7-sonnet',
};
const result = bedrockInputParser.parse(input) as BedrockConverseInput;
const additionalFields = result.additionalModelRequestFields as Record<string, unknown>;
expect(additionalFields.thinking).toBe(true);
expect(additionalFields.thinkingBudget).toBe(2000);
expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']);
});
test('should match anthropic.claude-sonnet-4 model', () => {
const input = {
model: 'anthropic.claude-sonnet-4',
};
const result = bedrockInputParser.parse(input) as BedrockConverseInput;
const additionalFields = result.additionalModelRequestFields as Record<string, unknown>;
expect(additionalFields.thinking).toBe(true);
expect(additionalFields.thinkingBudget).toBe(2000);
expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']);
});
test('should match anthropic.claude-opus-5 model', () => {
const input = {
model: 'anthropic.claude-opus-5',
};
const result = bedrockInputParser.parse(input) as BedrockConverseInput;
const additionalFields = result.additionalModelRequestFields as Record<string, unknown>;
expect(additionalFields.thinking).toBe(true);
expect(additionalFields.thinkingBudget).toBe(2000);
expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']);
});
test('should match anthropic.claude-haiku-6 model', () => {
const input = {
model: 'anthropic.claude-haiku-6',
};
const result = bedrockInputParser.parse(input) as BedrockConverseInput;
const additionalFields = result.additionalModelRequestFields as Record<string, unknown>;
expect(additionalFields.thinking).toBe(true);
expect(additionalFields.thinkingBudget).toBe(2000);
expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']);
});
test('should match anthropic.claude-4-sonnet model', () => {
const input = {
model: 'anthropic.claude-4-sonnet',
};
const result = bedrockInputParser.parse(input) as BedrockConverseInput;
const additionalFields = result.additionalModelRequestFields as Record<string, unknown>;
expect(additionalFields.thinking).toBe(true);
expect(additionalFields.thinkingBudget).toBe(2000);
expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']);
});
test('should match anthropic.claude-4.5-sonnet model', () => {
const input = {
model: 'anthropic.claude-4.5-sonnet',
};
const result = bedrockInputParser.parse(input) as BedrockConverseInput;
const additionalFields = result.additionalModelRequestFields as Record<string, unknown>;
expect(additionalFields.thinking).toBe(true);
expect(additionalFields.thinkingBudget).toBe(2000);
expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']);
});
test('should match anthropic.claude-4-7-sonnet model', () => {
const input = {
model: 'anthropic.claude-4-7-sonnet',
};
const result = bedrockInputParser.parse(input) as BedrockConverseInput;
const additionalFields = result.additionalModelRequestFields as Record<string, unknown>;
expect(additionalFields.thinking).toBe(true);
expect(additionalFields.thinkingBudget).toBe(2000);
expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']);
});
test('should not match non-Claude models', () => {
const input = {
model: 'some-other-model',
};
const result = bedrockInputParser.parse(input) as BedrockConverseInput;
expect(result.additionalModelRequestFields).toBeUndefined();
});
test('should respect explicit thinking configuration', () => {
const input = {
model: 'anthropic.claude-sonnet-4',
thinking: false,
};
const result = bedrockInputParser.parse(input) as BedrockConverseInput;
const additionalFields = result.additionalModelRequestFields as Record<string, unknown>;
expect(additionalFields.thinking).toBeUndefined();
expect(additionalFields.thinkingBudget).toBeUndefined();
});
test('should respect custom thinking budget', () => {
const input = {
model: 'anthropic.claude-sonnet-4',
thinking: true,
thinkingBudget: 3000,
};
const result = bedrockInputParser.parse(input) as BedrockConverseInput;
const additionalFields = result.additionalModelRequestFields as Record<string, unknown>;
expect(additionalFields.thinking).toBe(true);
expect(additionalFields.thinkingBudget).toBe(3000);
});
});
});

View file

@ -0,0 +1,903 @@
import type {
ScraperTypes,
TCustomConfig,
RerankerTypes,
SearchProviders,
TWebSearchConfig,
} from '../src/config';
import { webSearchAuth, loadWebSearchAuth, extractWebSearchEnvVars } from '../src/web';
import { SafeSearchTypes } from '../src/config';
import { AuthType } from '../src/schemas';
// Mock the extractVariableName function
jest.mock('../src/utils', () => ({
extractVariableName: (value: string) => {
if (!value || typeof value !== 'string') return null;
const match = value.match(/^\${(.+)}$/);
return match ? match[1] : null;
},
}));
describe('web.ts', () => {
describe('extractWebSearchEnvVars', () => {
it('should return empty array if config is undefined', () => {
const result = extractWebSearchEnvVars({
keys: ['serperApiKey', 'jinaApiKey'],
config: undefined,
});
expect(result).toEqual([]);
});
it('should extract environment variable names from config values', () => {
const config: Partial<TWebSearchConfig> = {
serperApiKey: '${SERPER_API_KEY}',
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: 'actual-api-key', // Not in env var format
safeSearch: SafeSearchTypes.MODERATE,
};
const result = extractWebSearchEnvVars({
keys: ['serperApiKey', 'jinaApiKey', 'cohereApiKey'],
config: config as TWebSearchConfig,
});
expect(result).toEqual(['SERPER_API_KEY', 'JINA_API_KEY']);
});
it('should only extract variables for keys that exist in the config', () => {
const config: Partial<TWebSearchConfig> = {
serperApiKey: '${SERPER_API_KEY}',
// firecrawlApiKey is missing
safeSearch: SafeSearchTypes.MODERATE,
};
const result = extractWebSearchEnvVars({
keys: ['serperApiKey', 'firecrawlApiKey'],
config: config as TWebSearchConfig,
});
expect(result).toEqual(['SERPER_API_KEY']);
});
});
describe('loadWebSearchAuth', () => {
// Common test variables
const userId = 'test-user-id';
let mockLoadAuthValues: jest.Mock;
let webSearchConfig: TCustomConfig['webSearch'];
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
// Initialize the mock function
mockLoadAuthValues = jest.fn();
// Initialize a basic webSearchConfig
webSearchConfig = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
};
});
it('should return authenticated=true when all required categories are authenticated', async () => {
// Mock successful authentication for all services
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
result[field] =
field === 'FIRECRAWL_API_URL' ? 'https://api.firecrawl.dev' : 'test-api-key';
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authenticated).toBe(true);
expect(result.authTypes).toHaveLength(3); // providers, scrapers, rerankers
expect(result.authResult).toHaveProperty('serperApiKey', 'test-api-key');
expect(result.authResult).toHaveProperty('firecrawlApiKey', 'test-api-key');
// The implementation only includes one reranker in the result
// It will be either jina or cohere, but not both
if (result.authResult.rerankerType === 'jina') {
expect(result.authResult).toHaveProperty('jinaApiKey', 'test-api-key');
} else {
expect(result.authResult).toHaveProperty('cohereApiKey', 'test-api-key');
}
expect(result.authResult).toHaveProperty('searchProvider', 'serper');
expect(result.authResult).toHaveProperty('scraperType', 'firecrawl');
expect(['jina', 'cohere']).toContain(result.authResult.rerankerType as string);
});
it('should return authenticated=false when a required category is not authenticated', async () => {
// Mock authentication failure for the providers category
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
// Only provide values for scrapers and rerankers, not for providers
if (field !== 'SERPER_API_KEY') {
result[field] =
field === 'FIRECRAWL_API_URL' ? 'https://api.firecrawl.dev' : 'test-api-key';
}
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authenticated).toBe(false);
// We should still have authTypes for the categories we checked
expect(result.authTypes.some(([category]) => category === 'providers')).toBe(true);
});
it('should handle exceptions from loadAuthValues', async () => {
// Mock loadAuthValues to throw an error
mockLoadAuthValues.mockImplementation(() => {
throw new Error('Authentication failed');
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
throwError: false, // Don't throw errors
});
expect(result.authenticated).toBe(false);
});
it('should correctly identify user-provided vs system-defined auth', async () => {
// Mock environment variables
const originalEnv = process.env;
process.env = {
...originalEnv,
SERPER_API_KEY: 'system-api-key',
FIRECRAWL_API_KEY: 'system-api-key',
JINA_API_KEY: 'system-api-key',
};
// Mock loadAuthValues to return different values for some keys
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
if (field === 'SERPER_API_KEY') {
// This matches the system env var
result[field] = 'system-api-key';
} else if (field === 'FIRECRAWL_API_KEY') {
// This is different from the system env var (user provided)
result[field] = 'user-api-key';
} else if (field === 'FIRECRAWL_API_URL') {
result[field] = 'https://api.firecrawl.dev';
} else if (field === 'JINA_API_KEY') {
// This matches the system env var
result[field] = 'system-api-key';
} else {
result[field] = 'test-api-key';
}
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authenticated).toBe(true);
// Check for providers (system-defined) and scrapers (user-provided)
const providersAuthType = result.authTypes.find(
([category]) => category === 'providers',
)?.[1];
const scrapersAuthType = result.authTypes.find(([category]) => category === 'scrapers')?.[1];
expect(providersAuthType).toBe(AuthType.SYSTEM_DEFINED);
expect(scrapersAuthType).toBe(AuthType.USER_PROVIDED);
// Restore original env
process.env = originalEnv;
});
it('should handle optional fields correctly', async () => {
// Create a config without the optional firecrawlApiUrl
const configWithoutOptional = { ...webSearchConfig } as Partial<TWebSearchConfig>;
delete configWithoutOptional.firecrawlApiUrl;
mockLoadAuthValues.mockImplementation(({ authFields, optional }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
// Don't provide values for optional fields
if (!optional?.has(field)) {
result[field] = 'test-api-key';
}
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig: configWithoutOptional as TWebSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authenticated).toBe(true);
expect(result.authResult).toHaveProperty('firecrawlApiKey', 'test-api-key');
// Optional URL should not be in the result
expect(result.authResult.firecrawlApiUrl).toBeUndefined();
});
it('should preserve safeSearch setting from webSearchConfig', async () => {
// Mock successful authentication
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
result[field] = 'test-api-key';
});
return Promise.resolve(result);
});
// Test with safeSearch: OFF
const configWithSafeSearchOff = {
...webSearchConfig,
safeSearch: SafeSearchTypes.OFF,
} as TWebSearchConfig;
const result = await loadWebSearchAuth({
userId,
webSearchConfig: configWithSafeSearchOff,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authResult).toHaveProperty('safeSearch', SafeSearchTypes.OFF);
});
it('should set the correct service types in authResult', async () => {
// Mock successful authentication
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
result[field] =
field === 'FIRECRAWL_API_URL' ? 'https://api.firecrawl.dev' : 'test-api-key';
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
// Check that the correct service types are set
expect(result.authResult.searchProvider).toBe('serper' as SearchProviders);
expect(result.authResult.scraperType).toBe('firecrawl' as ScraperTypes);
// One of the rerankers should be set
expect(['jina', 'cohere']).toContain(result.authResult.rerankerType as string);
});
it('should check all services if none are specified', async () => {
// Initialize a webSearchConfig without specific services
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
};
// Mock successful authentication
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
result[field] =
field === 'FIRECRAWL_API_URL' ? 'https://api.firecrawl.dev' : 'test-api-key';
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authenticated).toBe(true);
// Should have checked all categories
expect(result.authTypes).toHaveLength(3);
// Should have set values for all categories
expect(result.authResult.searchProvider).toBeDefined();
expect(result.authResult.scraperType).toBeDefined();
expect(result.authResult.rerankerType).toBeDefined();
});
it('should correctly identify authTypes based on specific configurations', async () => {
// Set up environment variables for system-defined auth
const originalEnv = process.env;
process.env = {
...originalEnv,
SERPER_API_KEY: 'system-serper-key',
FIRECRAWL_API_KEY: 'system-firecrawl-key',
FIRECRAWL_API_URL: 'https://api.firecrawl.dev',
JINA_API_KEY: 'system-jina-key',
COHERE_API_KEY: 'system-cohere-key',
};
// Initialize webSearchConfig with environment variable references
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
// Specify which services to use
searchProvider: 'serper' as SearchProviders,
scraperType: 'firecrawl' as ScraperTypes,
rerankerType: 'jina' as RerankerTypes,
};
// Mock loadAuthValues to return the actual values
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
if (field === 'SERPER_API_KEY') {
result[field] = 'system-serper-key';
} else if (field === 'FIRECRAWL_API_KEY') {
result[field] = 'system-firecrawl-key';
} else if (field === 'FIRECRAWL_API_URL') {
result[field] = 'https://api.firecrawl.dev';
} else if (field === 'JINA_API_KEY') {
result[field] = 'system-jina-key';
} else if (field === 'COHERE_API_KEY') {
result[field] = 'system-cohere-key';
}
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
// Verify that all required fields are present in the authResult
expect(result.authResult).toHaveProperty('serperApiKey');
expect(result.authResult).toHaveProperty('firecrawlApiKey');
expect(result.authResult).toHaveProperty('firecrawlApiUrl');
expect(result.authResult).toHaveProperty('jinaApiKey');
expect(result.authResult).toHaveProperty('searchProvider');
expect(result.authResult).toHaveProperty('scraperType');
expect(result.authResult).toHaveProperty('rerankerType');
expect(result.authenticated).toBe(true);
// Verify authTypes for each category
const providersAuthType = result.authTypes.find(
([category]) => category === 'providers',
)?.[1];
const scrapersAuthType = result.authTypes.find(([category]) => category === 'scrapers')?.[1];
const rerankersAuthType = result.authTypes.find(
([category]) => category === 'rerankers',
)?.[1];
// All should be system-defined since we're using environment variables
expect(providersAuthType).toBe(AuthType.SYSTEM_DEFINED);
expect(scrapersAuthType).toBe(AuthType.SYSTEM_DEFINED);
expect(rerankersAuthType).toBe(AuthType.SYSTEM_DEFINED);
// Verify the authResult contains the correct values
expect(result.authResult).toHaveProperty('serperApiKey', 'system-serper-key');
expect(result.authResult).toHaveProperty('firecrawlApiKey', 'system-firecrawl-key');
expect(result.authResult).toHaveProperty('firecrawlApiUrl', 'https://api.firecrawl.dev');
expect(result.authResult).toHaveProperty('jinaApiKey', 'system-jina-key');
expect(result.authResult).toHaveProperty('searchProvider', 'serper');
expect(result.authResult).toHaveProperty('scraperType', 'firecrawl');
expect(result.authResult).toHaveProperty('rerankerType', 'jina');
// Restore original env
process.env = originalEnv;
});
it('should handle custom variable names in environment variables', async () => {
// Set up environment variables with custom names
const originalEnv = process.env;
process.env = {
...originalEnv,
CUSTOM_SERPER_KEY: 'custom-serper-key',
CUSTOM_FIRECRAWL_KEY: 'custom-firecrawl-key',
CUSTOM_FIRECRAWL_URL: 'https://custom.firecrawl.dev',
CUSTOM_JINA_KEY: 'custom-jina-key',
CUSTOM_COHERE_KEY: 'custom-cohere-key',
};
// Initialize webSearchConfig with custom variable names
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${CUSTOM_SERPER_KEY}',
firecrawlApiKey: '${CUSTOM_FIRECRAWL_KEY}',
firecrawlApiUrl: '${CUSTOM_FIRECRAWL_URL}',
jinaApiKey: '${CUSTOM_JINA_KEY}',
cohereApiKey: '${CUSTOM_COHERE_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
// Specify which services to use
searchProvider: 'serper' as SearchProviders,
scraperType: 'firecrawl' as ScraperTypes,
rerankerType: 'jina' as RerankerTypes, // Only Jina will be checked
};
// Mock loadAuthValues to return the actual values
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
if (field === 'CUSTOM_SERPER_KEY') {
result[field] = 'custom-serper-key';
} else if (field === 'CUSTOM_FIRECRAWL_KEY') {
result[field] = 'custom-firecrawl-key';
} else if (field === 'CUSTOM_FIRECRAWL_URL') {
result[field] = 'https://custom.firecrawl.dev';
} else if (field === 'CUSTOM_JINA_KEY') {
result[field] = 'custom-jina-key';
}
// Note: CUSTOM_COHERE_KEY is not checked because we specified jina as rerankerType
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authenticated).toBe(true);
// Verify the authResult contains the correct values from custom variables
expect(result.authResult).toHaveProperty('serperApiKey', 'custom-serper-key');
expect(result.authResult).toHaveProperty('firecrawlApiKey', 'custom-firecrawl-key');
expect(result.authResult).toHaveProperty('firecrawlApiUrl', 'https://custom.firecrawl.dev');
expect(result.authResult).toHaveProperty('jinaApiKey', 'custom-jina-key');
// cohereApiKey should not be in the result since we specified jina as rerankerType
expect(result.authResult).not.toHaveProperty('cohereApiKey');
// Verify the service types are set correctly
expect(result.authResult).toHaveProperty('searchProvider', 'serper');
expect(result.authResult).toHaveProperty('scraperType', 'firecrawl');
expect(result.authResult).toHaveProperty('rerankerType', 'jina');
// Restore original env
process.env = originalEnv;
});
it('should always return authTypes array with exactly 3 categories', async () => {
// Set up environment variables
const originalEnv = process.env;
process.env = {
...originalEnv,
SERPER_API_KEY: 'test-key',
FIRECRAWL_API_KEY: 'test-key',
FIRECRAWL_API_URL: 'https://api.firecrawl.dev',
JINA_API_KEY: 'test-key',
};
// Initialize webSearchConfig with environment variable references
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
};
// Mock loadAuthValues to return values
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
result[field] = field === 'FIRECRAWL_API_URL' ? 'https://api.firecrawl.dev' : 'test-key';
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
// Get the number of categories from webSearchAuth
const expectedCategoryCount = Object.keys(webSearchAuth).length;
// Verify authTypes array structure
expect(result.authTypes).toHaveLength(expectedCategoryCount);
// Verify each category exists exactly once
const categories = result.authTypes.map(([category]) => category);
Object.keys(webSearchAuth).forEach((category) => {
expect(categories).toContain(category);
});
// Verify no duplicate categories
expect(new Set(categories).size).toBe(expectedCategoryCount);
// Verify each entry has the correct format [category, AuthType]
result.authTypes.forEach(([category, authType]) => {
expect(typeof category).toBe('string');
expect([AuthType.SYSTEM_DEFINED, AuthType.USER_PROVIDED]).toContain(authType);
});
// Restore original env
process.env = originalEnv;
});
it('should maintain authTypes array structure even when authentication fails', async () => {
// Set up environment variables
const originalEnv = process.env;
process.env = {
...originalEnv,
SERPER_API_KEY: 'test-key',
// Missing other keys to force authentication failure
};
// Initialize webSearchConfig with environment variable references
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
};
// Mock loadAuthValues to return partial values
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
if (field === 'SERPER_API_KEY') {
result[field] = 'test-key';
}
// Other fields are intentionally missing
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
// Get the number of categories from webSearchAuth
const expectedCategoryCount = Object.keys(webSearchAuth).length;
// Verify authentication failed
expect(result.authenticated).toBe(false);
// Verify authTypes array structure is maintained
expect(result.authTypes).toHaveLength(expectedCategoryCount);
// Verify each category exists exactly once
const categories = result.authTypes.map(([category]) => category);
Object.keys(webSearchAuth).forEach((category) => {
expect(categories).toContain(category);
});
// Verify no duplicate categories
expect(new Set(categories).size).toBe(expectedCategoryCount);
// Verify each entry has the correct format [category, AuthType]
result.authTypes.forEach(([category, authType]) => {
expect(typeof category).toBe('string');
expect([AuthType.SYSTEM_DEFINED, AuthType.USER_PROVIDED]).toContain(authType);
});
// Restore original env
process.env = originalEnv;
});
});
describe('webSearchAuth', () => {
it('should have the expected structure', () => {
// Check that all expected categories exist
expect(webSearchAuth).toHaveProperty('providers');
expect(webSearchAuth).toHaveProperty('scrapers');
expect(webSearchAuth).toHaveProperty('rerankers');
// Check providers
expect(webSearchAuth.providers).toHaveProperty('serper');
expect(webSearchAuth.providers.serper).toHaveProperty('serperApiKey', 1);
// Check scrapers
expect(webSearchAuth.scrapers).toHaveProperty('firecrawl');
expect(webSearchAuth.scrapers.firecrawl).toHaveProperty('firecrawlApiKey', 1);
expect(webSearchAuth.scrapers.firecrawl).toHaveProperty('firecrawlApiUrl', 0);
// Check rerankers
expect(webSearchAuth.rerankers).toHaveProperty('jina');
expect(webSearchAuth.rerankers.jina).toHaveProperty('jinaApiKey', 1);
expect(webSearchAuth.rerankers).toHaveProperty('cohere');
expect(webSearchAuth.rerankers.cohere).toHaveProperty('cohereApiKey', 1);
});
it('should mark required keys with value 1', () => {
// All keys with value 1 are required
expect(webSearchAuth.providers.serper.serperApiKey).toBe(1);
expect(webSearchAuth.scrapers.firecrawl.firecrawlApiKey).toBe(1);
expect(webSearchAuth.rerankers.jina.jinaApiKey).toBe(1);
expect(webSearchAuth.rerankers.cohere.cohereApiKey).toBe(1);
});
it('should mark optional keys with value 0', () => {
// Keys with value 0 are optional
expect(webSearchAuth.scrapers.firecrawl.firecrawlApiUrl).toBe(0);
});
});
describe('loadWebSearchAuth with specific services', () => {
// Common test variables
const userId = 'test-user-id';
let mockLoadAuthValues: jest.Mock;
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
// Initialize the mock function
mockLoadAuthValues = jest.fn();
});
it('should only check the specified searchProvider', async () => {
// Initialize a webSearchConfig with a specific searchProvider
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
searchProvider: 'serper' as SearchProviders,
};
// Mock successful authentication
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
result[field] =
field === 'FIRECRAWL_API_URL' ? 'https://api.firecrawl.dev' : 'test-api-key';
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authenticated).toBe(true);
expect(result.authResult.searchProvider).toBe('serper');
// Verify that only SERPER_API_KEY was requested for the providers category
const providerCalls = mockLoadAuthValues.mock.calls.filter((call) =>
call[0].authFields.includes('SERPER_API_KEY'),
);
expect(providerCalls.length).toBe(1);
});
it('should only check the specified scraperType', async () => {
// Initialize a webSearchConfig with a specific scraperType
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
scraperType: 'firecrawl' as ScraperTypes,
};
// Mock successful authentication
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
result[field] =
field === 'FIRECRAWL_API_URL' ? 'https://api.firecrawl.dev' : 'test-api-key';
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authenticated).toBe(true);
expect(result.authResult.scraperType).toBe('firecrawl');
// Verify that only FIRECRAWL_API_KEY and FIRECRAWL_API_URL were requested for the scrapers category
const scraperCalls = mockLoadAuthValues.mock.calls.filter((call) =>
call[0].authFields.includes('FIRECRAWL_API_KEY'),
);
expect(scraperCalls.length).toBe(1);
});
it('should only check the specified rerankerType', async () => {
// Initialize a webSearchConfig with a specific rerankerType
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
rerankerType: 'jina' as RerankerTypes,
};
// Mock successful authentication
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
result[field] =
field === 'FIRECRAWL_API_URL' ? 'https://api.firecrawl.dev' : 'test-api-key';
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authenticated).toBe(true);
expect(result.authResult.rerankerType).toBe('jina');
// Verify that only JINA_API_KEY was requested for the rerankers category
const rerankerCalls = mockLoadAuthValues.mock.calls.filter((call) =>
call[0].authFields.includes('JINA_API_KEY'),
);
expect(rerankerCalls.length).toBe(1);
// Verify that COHERE_API_KEY was not requested
const cohereCalls = mockLoadAuthValues.mock.calls.filter((call) =>
call[0].authFields.includes('COHERE_API_KEY'),
);
expect(cohereCalls.length).toBe(0);
});
it('should handle invalid specified service gracefully', async () => {
// Initialize a webSearchConfig with an invalid searchProvider
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
searchProvider: 'invalid-provider' as SearchProviders,
};
// Mock successful authentication
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
result[field] =
field === 'FIRECRAWL_API_URL' ? 'https://api.firecrawl.dev' : 'test-api-key';
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
// Should fail because the specified provider doesn't exist
expect(result.authenticated).toBe(false);
});
it('should fail authentication when specified service is not authenticated but others are', async () => {
// Initialize a webSearchConfig with a specific rerankerType (jina)
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
rerankerType: 'jina' as RerankerTypes,
};
// Mock authentication where cohere is authenticated but jina is not
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
// Authenticate all fields except JINA_API_KEY
if (field !== 'JINA_API_KEY') {
result[field] =
field === 'FIRECRAWL_API_URL' ? 'https://api.firecrawl.dev' : 'test-api-key';
}
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
// Should fail because the specified reranker (jina) is not authenticated
// even though another reranker (cohere) might be authenticated
expect(result.authenticated).toBe(false);
// Verify that JINA_API_KEY was requested
const jinaApiKeyCalls = mockLoadAuthValues.mock.calls.filter((call) =>
call[0].authFields.includes('JINA_API_KEY'),
);
expect(jinaApiKeyCalls.length).toBe(1);
// Verify that COHERE_API_KEY was not requested since we specified jina
const cohereApiKeyCalls = mockLoadAuthValues.mock.calls.filter((call) =>
call[0].authFields.includes('COHERE_API_KEY'),
);
expect(cohereApiKeyCalls.length).toBe(0);
});
it('should check all services if none are specified', async () => {
// Initialize a webSearchConfig without specific services
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
};
// Mock successful authentication
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field) => {
result[field] =
field === 'FIRECRAWL_API_URL' ? 'https://api.firecrawl.dev' : 'test-api-key';
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authenticated).toBe(true);
// Should have checked all categories
expect(result.authTypes).toHaveLength(3);
// Should have set values for all categories
expect(result.authResult.searchProvider).toBeDefined();
expect(result.authResult.scraperType).toBeDefined();
expect(result.authResult.rerankerType).toBeDefined();
});
});
});

View file

@ -187,6 +187,8 @@ export const agents = ({ path = '', options }: { path?: string; options?: object
return url;
};
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;
export const files = () => '/api/files';
export const images = () => `${files()}/images`;

View file

@ -119,7 +119,10 @@ export const bedrockInputParser = s.tConversationSchema
/** Default thinking and thinkingBudget for 'anthropic.claude-3-7-sonnet' models, if not defined */
if (
typeof typedData.model === 'string' &&
typedData.model.includes('anthropic.claude-3-7-sonnet')
(typedData.model.includes('anthropic.claude-3-7-sonnet') ||
/anthropic\.claude-(?:[4-9](?:\.\d+)?(?:-\d+)?-(?:sonnet|opus|haiku)|(?:sonnet|opus|haiku)-[4-9])/.test(
typedData.model,
))
) {
if (additionalFields.thinking === undefined) {
additionalFields.thinking = true;

View file

@ -7,7 +7,7 @@ import { fileConfigSchema } from './file-config';
import { FileSources } from './types/files';
import { MCPServersSchema } from './mcp';
export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord'];
export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord', 'saml'];
export const defaultRetrievalModels = [
'gpt-4o',
@ -52,6 +52,7 @@ export const excludedKeys = new Set([
'model',
'files',
'spec',
'disableParams',
]);
export enum SettingsViews {
@ -166,6 +167,7 @@ export enum AgentCapabilities {
end_after_tools = 'end_after_tools',
execute_code = 'execute_code',
file_search = 'file_search',
web_search = 'web_search',
artifacts = 'artifacts',
actions = 'actions',
tools = 'tools',
@ -231,6 +233,17 @@ export const assistantEndpointSchema = baseEndpointSchema.merge(
export type TAssistantEndpoint = z.infer<typeof assistantEndpointSchema>;
export const defaultAgentCapabilities = [
AgentCapabilities.execute_code,
AgentCapabilities.file_search,
AgentCapabilities.web_search,
AgentCapabilities.artifacts,
AgentCapabilities.actions,
AgentCapabilities.tools,
AgentCapabilities.chain,
AgentCapabilities.ocr,
];
export const agentsEndpointSChema = baseEndpointSchema.merge(
z.object({
/* agents specific */
@ -241,15 +254,7 @@ export const agentsEndpointSChema = baseEndpointSchema.merge(
capabilities: z
.array(z.nativeEnum(AgentCapabilities))
.optional()
.default([
AgentCapabilities.execute_code,
AgentCapabilities.file_search,
AgentCapabilities.artifacts,
AgentCapabilities.actions,
AgentCapabilities.tools,
AgentCapabilities.ocr,
AgentCapabilities.chain,
]),
.default(defaultAgentCapabilities),
}),
);
@ -278,6 +283,12 @@ export const endpointSchema = baseEndpointSchema.merge(
headers: z.record(z.any()).optional(),
addParams: z.record(z.any()).optional(),
dropParams: z.array(z.string()).optional(),
customParams: z
.object({
defaultParamsEndpoint: z.string().default('custom'),
paramDefinitions: z.array(z.record(z.any())).optional(),
})
.strict(),
customOrder: z.number().optional(),
directEndpoint: z.boolean().optional(),
titleMessageRole: z.string().optional(),
@ -486,6 +497,7 @@ export const intefaceSchema = z
agents: z.boolean().optional(),
temporaryChat: z.boolean().optional(),
runCode: z.boolean().optional(),
webSearch: z.boolean().optional(),
})
.default({
endpointsMenu: true,
@ -499,6 +511,7 @@ export const intefaceSchema = z
agents: true,
temporaryChat: true,
runCode: true,
webSearch: true,
});
export type TInterfaceConfig = z.infer<typeof intefaceSchema>;
@ -533,9 +546,12 @@ export type TStartupConfig = {
googleLoginEnabled: boolean;
openidLoginEnabled: boolean;
appleLoginEnabled: boolean;
samlLoginEnabled: boolean;
openidLabel: string;
openidImageUrl: string;
openidAutoRedirect: boolean;
samlLabel: string;
samlImageUrl: string;
/** LDAP Auth Configuration */
ldap?: {
/** LDAP enabled */
@ -559,6 +575,11 @@ export type TStartupConfig = {
instanceProjectId: string;
bundlerURL?: string;
staticBundlerURL?: string;
webSearch?: {
searchProvider?: SearchProviders;
scraperType?: ScraperTypes;
rerankerType?: RerankerTypes;
};
};
export enum OCRStrategy {
@ -566,10 +587,52 @@ export enum OCRStrategy {
CUSTOM_OCR = 'custom_ocr',
}
export enum SearchCategories {
PROVIDERS = 'providers',
SCRAPERS = 'scrapers',
RERANKERS = 'rerankers',
}
export enum SearchProviders {
SERPER = 'serper',
SEARXNG = 'searxng',
}
export enum ScraperTypes {
FIRECRAWL = 'firecrawl',
SERPER = 'serper',
}
export enum RerankerTypes {
JINA = 'jina',
COHERE = 'cohere',
}
export enum SafeSearchTypes {
OFF = 0,
MODERATE = 1,
STRICT = 2,
}
export const webSearchSchema = z.object({
serperApiKey: z.string().optional().default('${SERPER_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}'),
cohereApiKey: z.string().optional().default('${COHERE_API_KEY}'),
searchProvider: z.nativeEnum(SearchProviders).optional(),
scraperType: z.nativeEnum(ScraperTypes).optional(),
rerankerType: z.nativeEnum(RerankerTypes).optional(),
scraperTimeout: z.number().optional(),
safeSearch: z.nativeEnum(SafeSearchTypes).default(SafeSearchTypes.MODERATE),
});
export type TWebSearchConfig = z.infer<typeof webSearchSchema>;
export const ocrSchema = z.object({
mistralModel: z.string().optional(),
apiKey: z.string().optional().default('OCR_API_KEY'),
baseURL: z.string().optional().default('OCR_BASEURL'),
apiKey: z.string().optional().default('${OCR_API_KEY}'),
baseURL: z.string().optional().default('${OCR_BASEURL}'),
strategy: z.nativeEnum(OCRStrategy).default(OCRStrategy.MISTRAL_OCR),
});
@ -589,6 +652,7 @@ export const configSchema = z.object({
version: z.string(),
cache: z.boolean().default(true),
ocr: ocrSchema.optional(),
webSearch: webSearchSchema.optional(),
secureImageLinks: z.boolean().optional(),
imageOutputType: z.nativeEnum(EImageOutputType).default(EImageOutputType.PNG),
includedTools: z.array(z.string()).optional(),
@ -727,6 +791,10 @@ const sharedOpenAIModels = [
];
const sharedAnthropicModels = [
'claude-sonnet-4-20250514',
'claude-sonnet-4-latest',
'claude-opus-4-20250514',
'claude-opus-4-latest',
'claude-3-7-sonnet-latest',
'claude-3-7-sonnet-20250219',
'claude-3-5-haiku-20241022',
@ -886,8 +954,7 @@ export const visionModels = [
'gemma',
'gemini-exp',
'gemini-1.5',
'gemini-2.0',
'gemini-2.5',
'gemini-2',
'gemini-3',
'moondream',
'llama3.2-vision',
@ -895,6 +962,10 @@ export const visionModels = [
'llama-3-2-11b-vision',
'llama-3.2-90b-vision',
'llama-3-2-90b-vision',
'llama-4',
'claude-opus-4',
'claude-sonnet-4',
'claude-haiku-4',
];
export enum VisionModes {
generative = 'generative',
@ -1040,6 +1111,10 @@ export enum CacheKeys {
* Key for s3 check intervals per user
*/
S3_EXPIRY_INTERVAL = 'S3_EXPIRY_INTERVAL',
/**
* key for open id exchanged tokens
*/
OPENID_EXCHANGED_TOKENS = 'OPENID_EXCHANGED_TOKENS',
}
/**
@ -1203,6 +1278,10 @@ export enum SettingsTabValues {
* Tab for Data Controls
*/
DATA = 'data',
/**
* Tab for Balance Settings
*/
BALANCE = 'balance',
/**
* Tab for Account Settings
*/
@ -1248,7 +1327,7 @@ export enum Constants {
/** Key for the app's version. */
VERSION = 'v0.7.8',
/** Key for the Custom Config's version (librechat.yaml). */
CONFIG_VERSION = '1.2.5',
CONFIG_VERSION = '1.2.6',
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
NO_PARENT = '00000000-0000-0000-0000-000000000000',
/** Standard value for the initial conversationId before a request is sent */
@ -1316,6 +1395,8 @@ export enum LocalStorageKeys {
LAST_MCP_ = 'LAST_MCP_',
/** Last checked toggle for Code Interpreter API per conversation ID */
LAST_CODE_TOGGLE_ = 'LAST_CODE_TOGGLE_',
/** Last checked toggle for Web Search per conversation ID */
LAST_WEB_SEARCH_TOGGLE_ = 'LAST_WEB_SEARCH_TOGGLE_',
}
export enum ForkOptions {

View file

@ -93,7 +93,7 @@ export function getUser(): Promise<t.TUser> {
return request.get(endpoints.user());
}
export function getUserBalance(): Promise<string> {
export function getUserBalance(): Promise<t.TBalanceResponse> {
return request.get(endpoints.balance());
}
@ -431,6 +431,14 @@ export const listAgents = (params: a.AgentListParams): Promise<a.AgentListRespon
);
};
export const revertAgentVersion = ({
agent_id,
version_index,
}: {
agent_id: string;
version_index: number;
}): Promise<a.Agent> => request.post(endpoints.revertAgentVersion(agent_id), { version_index });
/* Tools */
export const getAvailableAgentTools = (): Promise<s.TPlugin[]> => {

View file

@ -222,6 +222,12 @@ export const fileConfigSchema = z.object({
endpoints: z.record(endpointFileConfigSchema).optional(),
serverFileSizeLimit: z.number().min(0).optional(),
avatarSizeLimit: z.number().min(0).optional(),
imageGeneration: z
.object({
percentage: z.number().min(0).max(100).optional(),
px: z.number().min(0).optional(),
})
.optional(),
});
/** Helper function to safely convert string patterns to RegExp objects */

View file

@ -358,7 +358,7 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
// continue;
}
setting.includeInput =
setting.type === SettingTypes.Number ? setting.includeInput ?? true : false; // Default to true if type is number
setting.type === SettingTypes.Number ? (setting.includeInput ?? true) : false; // Default to true if type is number
}
if (setting.component === ComponentTypes.Slider && setting.type === SettingTypes.Number) {
@ -445,7 +445,8 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
// Validate optionType and conversation schema
if (setting.optionType !== OptionTypes.Custom) {
const conversationSchema = tConversationSchema.shape[setting.key as keyof TConversation];
const conversationSchema =
tConversationSchema.shape[setting.key as keyof Omit<TConversation, 'disableParams'>];
if (!conversationSchema) {
errors.push({
code: ZodIssueCode.custom,
@ -466,7 +467,7 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
}
/* Default value checks */
if (setting.type === SettingTypes.Number && isNaN(setting.default as number)) {
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.`,
@ -474,7 +475,7 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
});
}
if (setting.type === SettingTypes.Boolean && typeof setting.default !== 'boolean') {
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.`,
@ -484,7 +485,7 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
if (
(setting.type === SettingTypes.String || setting.type === SettingTypes.Enum) &&
typeof setting.default !== 'string'
typeof setting.default !== 'string' && setting.default != null
) {
errors.push({
code: ZodIssueCode.custom,

View file

@ -14,6 +14,8 @@ export * from './generate';
export * from './models';
/* mcp */
export * from './mcp';
/* web search */
export * from './web';
/* RBAC */
export * from './permissions';
export * from './roles';
@ -25,6 +27,7 @@ export * from './types/files';
export * from './types/mutations';
export * from './types/queries';
export * from './types/runs';
export * from './types/web';
/* query/mutation keys */
export * from './keys';
/* api call helpers */
@ -36,3 +39,4 @@ import * as dataService from './data-service';
export * from './utils';
export * from './actions';
export { default as createPayload } from './createPayload';
export * from './parameterSettings';

View file

@ -65,6 +65,7 @@ export enum MutationKeys {
updateAgentAction = 'updateAgentAction',
deleteAction = 'deleteAction',
deleteAgentAction = 'deleteAgentAction',
revertAgentVersion = 'revertAgentVersion',
deleteUser = 'deleteUser',
updateRole = 'updateRole',
enableTwoFactor = 'enableTwoFactor',

View file

@ -53,9 +53,10 @@ export const WebSocketOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('websocket').optional(),
url: z
.string()
.url()
.transform((val: string) => extractEnvVariable(val))
.pipe(z.string().url())
.refine(
(val) => {
(val: string) => {
const protocol = new URL(val).protocol;
return protocol === 'ws:' || protocol === 'wss:';
},
@ -70,9 +71,10 @@ export const SSEOptionsSchema = BaseOptionsSchema.extend({
headers: z.record(z.string(), z.string()).optional(),
url: z
.string()
.url()
.transform((val: string) => extractEnvVariable(val))
.pipe(z.string().url())
.refine(
(val) => {
(val: string) => {
const protocol = new URL(val).protocol;
return protocol !== 'ws:' && protocol !== 'wss:';
},
@ -85,15 +87,19 @@ export const SSEOptionsSchema = BaseOptionsSchema.extend({
export const StreamableHTTPOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('streamable-http'),
headers: z.record(z.string(), z.string()).optional(),
url: z.string().url().refine(
(val) => {
url: z
.string()
.transform((val: string) => extractEnvVariable(val))
.pipe(z.string().url())
.refine(
(val: string) => {
const protocol = new URL(val).protocol;
return protocol !== 'ws:' && protocol !== 'wss:';
},
{
message: 'Streamable HTTP URL must not start with ws:// or wss://',
},
),
),
});
export const MCPOptionsSchema = z.union([
@ -138,5 +144,9 @@ export function processMCPEnv(obj: Readonly<MCPOptions>, userId?: string): MCPOp
newObj.headers = processedHeaders;
}
if ('url' in newObj && newObj.url) {
newObj.url = extractEnvVariable(newObj.url);
}
return newObj;
}

View file

@ -0,0 +1,726 @@
import {
ImageDetail,
EModelEndpoint,
openAISettings,
googleSettings,
ReasoningEffort,
BedrockProviders,
anthropicSettings,
} from './types';
import { SettingDefinition, SettingsConfiguration } from './generate';
// Base definitions
const baseDefinitions: Record<string, SettingDefinition> = {
model: {
key: 'model',
label: 'com_ui_model',
labelCode: true,
type: 'string',
component: 'dropdown',
optionType: 'model',
selectPlaceholder: 'com_ui_select_model',
searchPlaceholder: 'com_ui_select_search_model',
searchPlaceholderCode: true,
selectPlaceholderCode: true,
columnSpan: 4,
},
temperature: {
key: 'temperature',
label: 'com_endpoint_temperature',
labelCode: true,
description: 'com_endpoint_openai_temp',
descriptionCode: true,
type: 'number',
component: 'slider',
optionType: 'model',
columnSpan: 4,
},
topP: {
key: 'topP',
label: 'com_endpoint_top_p',
labelCode: true,
description: 'com_endpoint_anthropic_topp',
descriptionCode: true,
type: 'number',
component: 'slider',
optionType: 'model',
columnSpan: 4,
},
stop: {
key: 'stop',
label: 'com_endpoint_stop',
labelCode: true,
description: 'com_endpoint_openai_stop',
descriptionCode: true,
placeholder: 'com_endpoint_stop_placeholder',
placeholderCode: true,
type: 'array',
default: [],
component: 'tags',
optionType: 'conversation',
minTags: 0,
maxTags: 4,
},
imageDetail: {
key: 'imageDetail',
label: 'com_endpoint_plug_image_detail',
labelCode: true,
description: 'com_endpoint_openai_detail',
descriptionCode: true,
type: 'enum',
default: ImageDetail.auto,
component: 'slider',
options: [ImageDetail.low, ImageDetail.auto, ImageDetail.high],
optionType: 'conversation',
columnSpan: 2,
},
};
const createDefinition = (
base: Partial<SettingDefinition>,
overrides: Partial<SettingDefinition>,
): SettingDefinition => {
return { ...base, ...overrides } as SettingDefinition;
};
const librechat: Record<string, SettingDefinition> = {
modelLabel: {
key: 'modelLabel',
label: 'com_endpoint_custom_name',
labelCode: true,
type: 'string',
default: '',
component: 'input',
placeholder: 'com_endpoint_openai_custom_name_placeholder',
placeholderCode: true,
optionType: 'conversation',
},
maxContextTokens: {
key: 'maxContextTokens',
label: 'com_endpoint_context_tokens',
labelCode: true,
type: 'number',
component: 'input',
placeholder: 'com_nav_theme_system',
placeholderCode: true,
description: 'com_endpoint_context_info',
descriptionCode: true,
optionType: 'model',
columnSpan: 2,
},
resendFiles: {
key: 'resendFiles',
label: 'com_endpoint_plug_resend_files',
labelCode: true,
description: 'com_endpoint_openai_resend_files',
descriptionCode: true,
type: 'boolean',
default: true,
component: 'switch',
optionType: 'conversation',
showDefault: false,
columnSpan: 2,
},
promptPrefix: {
key: 'promptPrefix',
label: 'com_endpoint_prompt_prefix',
labelCode: true,
type: 'string',
default: '',
component: 'textarea',
placeholder: 'com_endpoint_openai_prompt_prefix_placeholder',
placeholderCode: true,
optionType: 'model',
},
};
const openAIParams: Record<string, SettingDefinition> = {
chatGptLabel: {
...librechat.modelLabel,
key: 'chatGptLabel',
},
promptPrefix: librechat.promptPrefix,
temperature: createDefinition(baseDefinitions.temperature, {
default: openAISettings.temperature.default,
range: {
min: openAISettings.temperature.min,
max: openAISettings.temperature.max,
step: openAISettings.temperature.step,
},
}),
top_p: createDefinition(baseDefinitions.topP, {
key: 'top_p',
default: openAISettings.top_p.default,
range: {
min: openAISettings.top_p.min,
max: openAISettings.top_p.max,
step: openAISettings.top_p.step,
},
}),
frequency_penalty: {
key: 'frequency_penalty',
label: 'com_endpoint_frequency_penalty',
labelCode: true,
description: 'com_endpoint_openai_freq',
descriptionCode: true,
type: 'number',
default: openAISettings.frequency_penalty.default,
range: {
min: openAISettings.frequency_penalty.min,
max: openAISettings.frequency_penalty.max,
step: openAISettings.frequency_penalty.step,
},
component: 'slider',
optionType: 'model',
columnSpan: 4,
},
presence_penalty: {
key: 'presence_penalty',
label: 'com_endpoint_presence_penalty',
labelCode: true,
description: 'com_endpoint_openai_pres',
descriptionCode: true,
type: 'number',
default: openAISettings.presence_penalty.default,
range: {
min: openAISettings.presence_penalty.min,
max: openAISettings.presence_penalty.max,
step: openAISettings.presence_penalty.step,
},
component: 'slider',
optionType: 'model',
columnSpan: 4,
},
max_tokens: {
key: 'max_tokens',
label: 'com_endpoint_max_output_tokens',
labelCode: true,
type: 'number',
component: 'input',
description: 'com_endpoint_openai_max_tokens',
descriptionCode: true,
placeholder: 'com_nav_theme_system',
placeholderCode: true,
optionType: 'model',
columnSpan: 2,
},
reasoning_effort: {
key: 'reasoning_effort',
label: 'com_endpoint_reasoning_effort',
labelCode: true,
description: 'com_endpoint_openai_reasoning_effort',
descriptionCode: true,
type: 'enum',
default: ReasoningEffort.medium,
component: 'slider',
options: [ReasoningEffort.low, ReasoningEffort.medium, ReasoningEffort.high],
optionType: 'model',
columnSpan: 4,
},
};
const anthropic: Record<string, SettingDefinition> = {
maxOutputTokens: {
key: 'maxOutputTokens',
label: 'com_endpoint_max_output_tokens',
labelCode: true,
type: 'number',
component: 'input',
description: 'com_endpoint_anthropic_maxoutputtokens',
descriptionCode: true,
placeholder: 'com_nav_theme_system',
placeholderCode: true,
range: {
min: anthropicSettings.maxOutputTokens.min,
max: anthropicSettings.maxOutputTokens.max,
step: anthropicSettings.maxOutputTokens.step,
},
optionType: 'model',
columnSpan: 2,
},
temperature: createDefinition(baseDefinitions.temperature, {
default: anthropicSettings.temperature.default,
range: {
min: anthropicSettings.temperature.min,
max: anthropicSettings.temperature.max,
step: anthropicSettings.temperature.step,
},
}),
topP: createDefinition(baseDefinitions.topP, {
default: anthropicSettings.topP.default,
range: {
min: anthropicSettings.topP.min,
max: anthropicSettings.topP.max,
step: anthropicSettings.topP.step,
},
}),
topK: {
key: 'topK',
label: 'com_endpoint_top_k',
labelCode: true,
description: 'com_endpoint_anthropic_topk',
descriptionCode: true,
type: 'number',
default: anthropicSettings.topK.default,
range: {
min: anthropicSettings.topK.min,
max: anthropicSettings.topK.max,
step: anthropicSettings.topK.step,
},
component: 'slider',
optionType: 'model',
columnSpan: 4,
},
promptCache: {
key: 'promptCache',
label: 'com_endpoint_prompt_cache',
labelCode: true,
description: 'com_endpoint_anthropic_prompt_cache',
descriptionCode: true,
type: 'boolean',
default: anthropicSettings.promptCache.default,
component: 'switch',
optionType: 'conversation',
showDefault: false,
columnSpan: 2,
},
thinking: {
key: 'thinking',
label: 'com_endpoint_thinking',
labelCode: true,
description: 'com_endpoint_anthropic_thinking',
descriptionCode: true,
type: 'boolean',
default: anthropicSettings.thinking.default,
component: 'switch',
optionType: 'conversation',
showDefault: false,
columnSpan: 2,
},
thinkingBudget: {
key: 'thinkingBudget',
label: 'com_endpoint_thinking_budget',
labelCode: true,
description: 'com_endpoint_anthropic_thinking_budget',
descriptionCode: true,
type: 'number',
component: 'input',
default: anthropicSettings.thinkingBudget.default,
range: {
min: anthropicSettings.thinkingBudget.min,
max: anthropicSettings.thinkingBudget.max,
step: anthropicSettings.thinkingBudget.step,
},
optionType: 'conversation',
columnSpan: 2,
},
};
const bedrock: Record<string, SettingDefinition> = {
system: {
key: 'system',
label: 'com_endpoint_prompt_prefix',
labelCode: true,
type: 'string',
default: '',
component: 'textarea',
placeholder: 'com_endpoint_openai_prompt_prefix_placeholder',
placeholderCode: true,
optionType: 'model',
},
region: {
key: 'region',
type: 'string',
label: 'com_ui_region',
labelCode: true,
component: 'combobox',
optionType: 'conversation',
selectPlaceholder: 'com_ui_select_region',
searchPlaceholder: 'com_ui_select_search_region',
searchPlaceholderCode: true,
selectPlaceholderCode: true,
columnSpan: 2,
},
maxTokens: {
key: 'maxTokens',
label: 'com_endpoint_max_output_tokens',
labelCode: true,
type: 'number',
component: 'input',
placeholder: 'com_endpoint_anthropic_maxoutputtokens',
placeholderCode: true,
optionType: 'model',
columnSpan: 2,
},
temperature: createDefinition(baseDefinitions.temperature, {
default: 1,
range: { min: 0, max: 1, step: 0.01 },
}),
topK: createDefinition(anthropic.topK, {
range: { min: 0, max: 500, step: 1 },
}),
topP: createDefinition(baseDefinitions.topP, {
default: 0.999,
range: { min: 0, max: 1, step: 0.01 },
}),
};
const mistral: Record<string, SettingDefinition> = {
temperature: createDefinition(baseDefinitions.temperature, {
default: 0.7,
range: { min: 0, max: 1, step: 0.01 },
}),
topP: createDefinition(baseDefinitions.topP, {
range: { min: 0, max: 1, step: 0.01 },
}),
};
const cohere: Record<string, SettingDefinition> = {
temperature: createDefinition(baseDefinitions.temperature, {
default: 0.3,
range: { min: 0, max: 1, step: 0.01 },
}),
topP: createDefinition(baseDefinitions.topP, {
default: 0.75,
range: { min: 0.01, max: 0.99, step: 0.01 },
}),
};
const meta: Record<string, SettingDefinition> = {
temperature: createDefinition(baseDefinitions.temperature, {
default: 0.5,
range: { min: 0, max: 1, step: 0.01 },
}),
topP: createDefinition(baseDefinitions.topP, {
default: 0.9,
range: { min: 0, max: 1, step: 0.01 },
}),
};
const google: Record<string, SettingDefinition> = {
temperature: createDefinition(baseDefinitions.temperature, {
default: googleSettings.temperature.default,
range: {
min: googleSettings.temperature.min,
max: googleSettings.temperature.max,
step: googleSettings.temperature.step,
},
}),
topP: createDefinition(baseDefinitions.topP, {
default: googleSettings.topP.default,
range: {
min: googleSettings.topP.min,
max: googleSettings.topP.max,
step: googleSettings.topP.step,
},
}),
topK: {
key: 'topK',
label: 'com_endpoint_top_k',
labelCode: true,
description: 'com_endpoint_google_topk',
descriptionCode: true,
type: 'number',
default: googleSettings.topK.default,
range: {
min: googleSettings.topK.min,
max: googleSettings.topK.max,
step: googleSettings.topK.step,
},
component: 'slider',
optionType: 'model',
columnSpan: 4,
},
maxOutputTokens: {
key: 'maxOutputTokens',
label: 'com_endpoint_max_output_tokens',
labelCode: true,
type: 'number',
component: 'input',
description: 'com_endpoint_google_maxoutputtokens',
descriptionCode: true,
placeholder: 'com_nav_theme_system',
placeholderCode: true,
default: googleSettings.maxOutputTokens.default,
range: {
min: googleSettings.maxOutputTokens.min,
max: googleSettings.maxOutputTokens.max,
step: googleSettings.maxOutputTokens.step,
},
optionType: 'model',
columnSpan: 2,
},
};
const googleConfig: SettingsConfiguration = [
librechat.modelLabel,
librechat.promptPrefix,
librechat.maxContextTokens,
google.maxOutputTokens,
google.temperature,
google.topP,
google.topK,
librechat.resendFiles,
];
const googleCol1: SettingsConfiguration = [
baseDefinitions.model as SettingDefinition,
librechat.modelLabel,
librechat.promptPrefix,
];
const googleCol2: SettingsConfiguration = [
librechat.maxContextTokens,
google.maxOutputTokens,
google.temperature,
google.topP,
google.topK,
librechat.resendFiles,
];
const openAI: SettingsConfiguration = [
librechat.modelLabel,
librechat.promptPrefix,
librechat.maxContextTokens,
openAIParams.max_tokens,
openAIParams.temperature,
openAIParams.top_p,
openAIParams.frequency_penalty,
openAIParams.presence_penalty,
baseDefinitions.stop,
librechat.resendFiles,
baseDefinitions.imageDetail,
openAIParams.reasoning_effort,
];
const openAICol1: SettingsConfiguration = [
baseDefinitions.model as SettingDefinition,
librechat.modelLabel,
librechat.promptPrefix,
];
const openAICol2: SettingsConfiguration = [
librechat.maxContextTokens,
openAIParams.max_tokens,
openAIParams.temperature,
openAIParams.top_p,
openAIParams.frequency_penalty,
openAIParams.presence_penalty,
baseDefinitions.stop,
openAIParams.reasoning_effort,
librechat.resendFiles,
baseDefinitions.imageDetail,
];
const anthropicConfig: SettingsConfiguration = [
librechat.modelLabel,
librechat.promptPrefix,
librechat.maxContextTokens,
anthropic.maxOutputTokens,
anthropic.temperature,
anthropic.topP,
anthropic.topK,
librechat.resendFiles,
anthropic.promptCache,
anthropic.thinking,
anthropic.thinkingBudget,
];
const anthropicCol1: SettingsConfiguration = [
baseDefinitions.model as SettingDefinition,
librechat.modelLabel,
librechat.promptPrefix,
];
const anthropicCol2: SettingsConfiguration = [
librechat.maxContextTokens,
anthropic.maxOutputTokens,
anthropic.temperature,
anthropic.topP,
anthropic.topK,
librechat.resendFiles,
anthropic.promptCache,
anthropic.thinking,
anthropic.thinkingBudget,
];
const bedrockAnthropic: SettingsConfiguration = [
librechat.modelLabel,
bedrock.system,
librechat.maxContextTokens,
bedrock.maxTokens,
bedrock.temperature,
bedrock.topP,
bedrock.topK,
baseDefinitions.stop,
librechat.resendFiles,
bedrock.region,
anthropic.thinking,
anthropic.thinkingBudget,
];
const bedrockMistral: SettingsConfiguration = [
librechat.modelLabel,
librechat.promptPrefix,
librechat.maxContextTokens,
bedrock.maxTokens,
mistral.temperature,
mistral.topP,
librechat.resendFiles,
bedrock.region,
];
const bedrockCohere: SettingsConfiguration = [
librechat.modelLabel,
librechat.promptPrefix,
librechat.maxContextTokens,
bedrock.maxTokens,
cohere.temperature,
cohere.topP,
librechat.resendFiles,
bedrock.region,
];
const bedrockGeneral: SettingsConfiguration = [
librechat.modelLabel,
librechat.promptPrefix,
librechat.maxContextTokens,
meta.temperature,
meta.topP,
librechat.resendFiles,
bedrock.region,
];
const bedrockAnthropicCol1: SettingsConfiguration = [
baseDefinitions.model as SettingDefinition,
librechat.modelLabel,
bedrock.system,
baseDefinitions.stop,
];
const bedrockAnthropicCol2: SettingsConfiguration = [
librechat.maxContextTokens,
bedrock.maxTokens,
bedrock.temperature,
bedrock.topP,
bedrock.topK,
librechat.resendFiles,
bedrock.region,
anthropic.thinking,
anthropic.thinkingBudget,
];
const bedrockMistralCol1: SettingsConfiguration = [
baseDefinitions.model as SettingDefinition,
librechat.modelLabel,
librechat.promptPrefix,
];
const bedrockMistralCol2: SettingsConfiguration = [
librechat.maxContextTokens,
bedrock.maxTokens,
mistral.temperature,
mistral.topP,
librechat.resendFiles,
bedrock.region,
];
const bedrockCohereCol1: SettingsConfiguration = [
baseDefinitions.model as SettingDefinition,
librechat.modelLabel,
librechat.promptPrefix,
];
const bedrockCohereCol2: SettingsConfiguration = [
librechat.maxContextTokens,
bedrock.maxTokens,
cohere.temperature,
cohere.topP,
librechat.resendFiles,
bedrock.region,
];
const bedrockGeneralCol1: SettingsConfiguration = [
baseDefinitions.model as SettingDefinition,
librechat.modelLabel,
librechat.promptPrefix,
];
const bedrockGeneralCol2: SettingsConfiguration = [
librechat.maxContextTokens,
meta.temperature,
meta.topP,
librechat.resendFiles,
bedrock.region,
];
export const paramSettings: Record<string, SettingsConfiguration | undefined> = {
[EModelEndpoint.openAI]: openAI,
[EModelEndpoint.azureOpenAI]: openAI,
[EModelEndpoint.custom]: openAI,
[EModelEndpoint.anthropic]: anthropicConfig,
[`${EModelEndpoint.bedrock}-${BedrockProviders.Anthropic}`]: bedrockAnthropic,
[`${EModelEndpoint.bedrock}-${BedrockProviders.MistralAI}`]: bedrockMistral,
[`${EModelEndpoint.bedrock}-${BedrockProviders.Cohere}`]: bedrockCohere,
[`${EModelEndpoint.bedrock}-${BedrockProviders.Meta}`]: bedrockGeneral,
[`${EModelEndpoint.bedrock}-${BedrockProviders.AI21}`]: bedrockGeneral,
[`${EModelEndpoint.bedrock}-${BedrockProviders.Amazon}`]: bedrockGeneral,
[`${EModelEndpoint.bedrock}-${BedrockProviders.DeepSeek}`]: bedrockGeneral,
[EModelEndpoint.google]: googleConfig,
};
const openAIColumns = {
col1: openAICol1,
col2: openAICol2,
};
const bedrockGeneralColumns = {
col1: bedrockGeneralCol1,
col2: bedrockGeneralCol2,
};
export const presetSettings: Record<
string,
| {
col1: SettingsConfiguration;
col2: SettingsConfiguration;
}
| undefined
> = {
[EModelEndpoint.openAI]: openAIColumns,
[EModelEndpoint.azureOpenAI]: openAIColumns,
[EModelEndpoint.custom]: openAIColumns,
[EModelEndpoint.anthropic]: {
col1: anthropicCol1,
col2: anthropicCol2,
},
[`${EModelEndpoint.bedrock}-${BedrockProviders.Anthropic}`]: {
col1: bedrockAnthropicCol1,
col2: bedrockAnthropicCol2,
},
[`${EModelEndpoint.bedrock}-${BedrockProviders.MistralAI}`]: {
col1: bedrockMistralCol1,
col2: bedrockMistralCol2,
},
[`${EModelEndpoint.bedrock}-${BedrockProviders.Cohere}`]: {
col1: bedrockCohereCol1,
col2: bedrockCohereCol2,
},
[`${EModelEndpoint.bedrock}-${BedrockProviders.Meta}`]: bedrockGeneralColumns,
[`${EModelEndpoint.bedrock}-${BedrockProviders.AI21}`]: bedrockGeneralColumns,
[`${EModelEndpoint.bedrock}-${BedrockProviders.Amazon}`]: bedrockGeneralColumns,
[`${EModelEndpoint.bedrock}-${BedrockProviders.DeepSeek}`]: bedrockGeneralColumns,
[EModelEndpoint.google]: {
col1: googleCol1,
col2: googleCol2,
},
};
export const agentParamSettings: Record<string, SettingsConfiguration | undefined> = Object.entries(
presetSettings,
).reduce<Record<string, SettingsConfiguration | undefined>>((acc, [key, value]) => {
if (value) {
acc[key] = value.col2;
}
return acc;
}, {});

View file

@ -28,6 +28,10 @@ export enum PermissionTypes {
* Type for using the "Run Code" LC Code Interpreter API feature
*/
RUN_CODE = 'RUN_CODE',
/**
* Type for using the "Web Search" feature
*/
WEB_SEARCH = 'WEB_SEARCH',
}
/**
@ -79,6 +83,11 @@ export const runCodePermissionsSchema = z.object({
});
export type TRunCodePermissions = z.infer<typeof runCodePermissionsSchema>;
export const webSearchPermissionsSchema = z.object({
[Permissions.USE]: z.boolean().default(true),
});
export type TWebSearchPermissions = z.infer<typeof webSearchPermissionsSchema>;
// Define a single permissions schema that holds all permission types.
export const permissionsSchema = z.object({
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
@ -87,4 +96,5 @@ export const permissionsSchema = z.object({
[PermissionTypes.MULTI_CONVO]: multiConvoPermissionsSchema,
[PermissionTypes.TEMPORARY_CHAT]: temporaryChatPermissionsSchema,
[PermissionTypes.RUN_CODE]: runCodePermissionsSchema,
[PermissionTypes.WEB_SEARCH]: webSearchPermissionsSchema,
});

View file

@ -6,6 +6,7 @@ import {
agentPermissionsSchema,
promptPermissionsSchema,
runCodePermissionsSchema,
webSearchPermissionsSchema,
bookmarkPermissionsSchema,
multiConvoPermissionsSchema,
temporaryChatPermissionsSchema,
@ -62,6 +63,9 @@ const defaultRolesSchema = z.object({
[PermissionTypes.RUN_CODE]: runCodePermissionsSchema.extend({
[Permissions.USE]: z.boolean().default(true),
}),
[PermissionTypes.WEB_SEARCH]: webSearchPermissionsSchema.extend({
[Permissions.USE]: z.boolean().default(true),
}),
}),
}),
[SystemRoles.USER]: roleSchema.extend({
@ -96,6 +100,9 @@ export const roleDefaults = defaultRolesSchema.parse({
[PermissionTypes.RUN_CODE]: {
[Permissions.USE]: true,
},
[PermissionTypes.WEB_SEARCH]: {
[Permissions.USE]: true,
},
},
},
[SystemRoles.USER]: {
@ -107,6 +114,7 @@ export const roleDefaults = defaultRolesSchema.parse({
[PermissionTypes.MULTI_CONVO]: {},
[PermissionTypes.TEMPORARY_CHAT]: {},
[PermissionTypes.RUN_CODE]: {},
[PermissionTypes.WEB_SEARCH]: {},
},
},
});

View file

@ -1,6 +1,7 @@
import { z } from 'zod';
import { Tools } from './types/assistants';
import type { TMessageContentParts, FunctionTool, FunctionToolCall } from './types/assistants';
import type { SearchResultData } from './types/web';
import type { TEphemeralAgent } from './types';
import type { TFile } from './types/files';
@ -101,7 +102,8 @@ export const isEphemeralAgent = (
}
const hasMCPSelected = (ephemeralAgent?.mcp?.length ?? 0) > 0;
const hasCodeSelected = (ephemeralAgent?.execute_code ?? false) === true;
return hasMCPSelected || hasCodeSelected;
const hasSearchSelected = (ephemeralAgent?.web_search ?? false) === true;
return hasMCPSelected || hasCodeSelected || hasSearchSelected;
};
export const isParamEndpoint = (
@ -177,6 +179,7 @@ export const defaultAgentFormValues = {
recursion_limit: undefined,
[Tools.execute_code]: false,
[Tools.file_search]: false,
[Tools.web_search]: false,
};
export const ImageVisionTool: FunctionTool = {
@ -517,7 +520,13 @@ export const tMessageSchema = z.object({
iconURL: z.string().nullable().optional(),
});
export type TAttachmentMetadata = { messageId: string; toolCallId: string };
export type TAttachmentMetadata = {
type?: Tools;
messageId: string;
toolCallId: string;
[Tools.web_search]?: SearchResultData;
};
export type TAttachment =
| (TFile & TAttachmentMetadata)
| (Pick<TFile, 'filename' | 'filepath' | 'conversationId'> & {
@ -745,6 +754,7 @@ export type TSetOption = (
export type TConversation = z.infer<typeof tConversationSchema> & {
presetOverride?: Partial<TPreset>;
disableParams?: boolean;
};
export const tSharedLinkSchema = z.object({

View file

@ -10,6 +10,7 @@ import type {
TConversationTag,
TBanner,
} from './schemas';
import { SettingDefinition } from './generate';
export type TOpenAIMessage = OpenAI.Chat.ChatCompletionMessageParam;
export * from './schemas';
@ -43,6 +44,7 @@ export type TEndpointOption = {
export type TEphemeralAgent = {
mcp?: string[];
web_search?: boolean;
execute_code?: boolean;
};
@ -78,7 +80,7 @@ export type EventSubmission = Omit<TSubmission, 'initialResponse'> & { initialRe
export type TPluginAction = {
pluginKey: string;
action: 'install' | 'uninstall';
auth?: unknown;
auth?: Partial<Record<string, string>>;
isEntityTool?: boolean;
};
@ -88,7 +90,7 @@ export type TUpdateUserPlugins = {
isEntityTool?: boolean;
pluginKey: string;
action: string;
auth?: unknown;
auth?: Partial<Record<string, string | null>>;
};
// TODO `label` needs to be changed to the proper `TranslationKeys`
@ -268,6 +270,10 @@ export type TConfig = {
disableBuilder?: boolean;
retrievalModels?: string[];
capabilities?: string[];
customParams?: {
defaultParamsEndpoint?: string;
paramDefinitions?: SettingDefinition[];
};
};
export type TEndpointsConfig =
@ -540,3 +546,13 @@ export type TAcceptTermsResponse = {
};
export type TBannerResponse = TBanner | null;
export type TBalanceResponse = {
tokenCredits: number;
// Automatic refill settings
autoRefillEnabled: boolean;
refillIntervalValue?: number;
refillIntervalUnit?: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months';
lastRefill?: Date;
refillAmount?: number;
};

View file

@ -19,6 +19,7 @@ export enum Tools {
execute_code = 'execute_code',
code_interpreter = 'code_interpreter',
file_search = 'file_search',
web_search = 'web_search',
retrieval = 'retrieval',
function = 'function',
}
@ -222,6 +223,7 @@ export type Agent = {
hide_sequential_outputs?: boolean;
artifacts?: ArtifactModes;
recursion_limit?: number;
version?: number;
};
export type TAgentsMap = Record<string, Agent | undefined>;

View file

@ -131,6 +131,7 @@ export type BatchFile = {
filepath: string;
embedded: boolean;
source: FileSources;
temp_file_id?: string;
};
export type DeleteFilesBody = {

View file

@ -129,7 +129,20 @@ export type UpdateAgentVariables = {
data: AgentUpdateParams;
};
export type UpdateAgentMutationOptions = MutationOptions<Agent, UpdateAgentVariables>;
export type DuplicateVersionError = Error & {
statusCode?: number;
details?: {
duplicateVersion?: unknown;
versionIndex?: number;
};
};
export type UpdateAgentMutationOptions = MutationOptions<
Agent,
UpdateAgentVariables,
unknown,
DuplicateVersionError
>;
export type DuplicateAgentBody = {
agent_id: string;
@ -159,6 +172,13 @@ export type DeleteAgentActionVariables = {
export type DeleteAgentActionOptions = MutationOptions<void, DeleteAgentActionVariables>;
export type RevertAgentVersionVariables = {
agent_id: string;
version_index: number;
};
export type RevertAgentVersionOptions = MutationOptions<Agent, RevertAgentVersionVariables>;
export type DeleteConversationOptions = MutationOptions<
types.TDeleteConversationResponse,
types.TDeleteConversationRequest

View file

@ -101,7 +101,11 @@ export type AllPromptGroupsResponse = t.TPromptGroup[];
export type ConversationTagsResponse = s.TConversationTag[];
export type VerifyToolAuthParams = { toolId: string };
export type VerifyToolAuthResponse = { authenticated: boolean; message?: string | s.AuthType };
export type VerifyToolAuthResponse = {
authenticated: boolean;
message?: string | s.AuthType;
authTypes?: [string, s.AuthType][];
};
export type GetToolCallParams = { conversationId: string };
export type ToolCallResults = a.ToolCallResult[];

View file

@ -0,0 +1,593 @@
import type { Logger as WinstonLogger } from 'winston';
import type { RunnableConfig } from '@langchain/core/runnables';
export type SearchRefType = 'search' | 'image' | 'news' | 'video' | 'ref';
export enum DATE_RANGE {
PAST_HOUR = 'h',
PAST_24_HOURS = 'd',
PAST_WEEK = 'w',
PAST_MONTH = 'm',
PAST_YEAR = 'y',
}
export type SearchProvider = 'serper' | 'searxng';
export type RerankerType = 'infinity' | 'jina' | 'cohere' | 'none';
export interface Highlight {
score: number;
text: string;
references?: UsedReferences;
}
export type ProcessedSource = {
content?: string;
attribution?: string;
references?: References;
highlights?: Highlight[];
processed?: boolean;
};
export type ProcessedOrganic = OrganicResult & ProcessedSource;
export type ProcessedTopStory = TopStoryResult & ProcessedSource;
export type ValidSource = ProcessedOrganic | ProcessedTopStory;
export type ResultReference = {
link: string;
type: 'link' | 'image' | 'video';
title?: string;
attribution?: string;
};
export interface SearchResultData {
turn?: number;
organic?: ProcessedOrganic[];
topStories?: ProcessedTopStory[];
images?: ImageResult[];
videos?: VideoResult[];
places?: PlaceResult[];
news?: NewsResult[];
shopping?: ShoppingResult[];
knowledgeGraph?: KnowledgeGraphResult;
answerBox?: AnswerBoxResult;
peopleAlsoAsk?: PeopleAlsoAskResult[];
relatedSearches?: Array<{ query: string }>;
references?: ResultReference[];
error?: string;
}
export interface SearchResult {
data?: SearchResultData;
error?: string;
success: boolean;
}
export interface Source {
link: string;
html?: string;
title?: string;
snippet?: string;
date?: string;
}
export interface SearchConfig {
searchProvider?: SearchProvider;
serperApiKey?: string;
searxngInstanceUrl?: string;
searxngApiKey?: string;
}
export type References = {
links: MediaReference[];
images: MediaReference[];
videos: MediaReference[];
};
export interface ScrapeResult {
url: string;
error?: boolean;
content: string;
attribution?: string;
references?: References;
highlights?: Highlight[];
}
export interface ProcessSourcesConfig {
topResults?: number;
strategies?: string[];
filterContent?: boolean;
reranker?: unknown;
logger?: Logger;
}
export interface FirecrawlConfig {
firecrawlApiKey?: string;
firecrawlApiUrl?: string;
firecrawlFormats?: string[];
}
export interface ScraperContentResult {
content: string;
}
export interface ScraperExtractionResult {
no_extraction: ScraperContentResult;
}
export interface JinaRerankerResult {
index: number;
relevance_score: number;
document?: string | { text: string };
}
export interface JinaRerankerResponse {
model: string;
usage: {
total_tokens: number;
};
results: JinaRerankerResult[];
}
export interface CohereRerankerResult {
index: number;
relevance_score: number;
}
export interface CohereRerankerResponse {
results: CohereRerankerResult[];
id: string;
meta: {
api_version: {
version: string;
is_experimental: boolean;
};
billed_units: {
search_units: number;
};
};
}
export type SafeSearchLevel = 0 | 1 | 2;
export type Logger = WinstonLogger;
export interface SearchToolConfig extends SearchConfig, ProcessSourcesConfig, FirecrawlConfig {
logger?: Logger;
safeSearch?: SafeSearchLevel;
jinaApiKey?: string;
cohereApiKey?: string;
rerankerType?: RerankerType;
onSearchResults?: (results: SearchResult, runnableConfig?: RunnableConfig) => void;
onGetHighlights?: (link: string) => void;
}
export interface MediaReference {
originalUrl: string;
title?: string;
text?: string;
}
export type UsedReferences = {
type: 'link' | 'image' | 'video';
originalIndex: number;
reference: MediaReference;
}[];
/** Firecrawl */
export interface FirecrawlScrapeOptions {
formats?: string[];
includeTags?: string[];
excludeTags?: string[];
headers?: Record<string, string>;
waitFor?: number;
timeout?: number;
}
export interface ScrapeMetadata {
// Core source information
sourceURL?: string;
url?: string;
scrapeId?: string;
statusCode?: number;
// Basic metadata
title?: string;
description?: string;
language?: string;
favicon?: string;
viewport?: string;
robots?: string;
'theme-color'?: string;
// Open Graph metadata
'og:url'?: string;
'og:title'?: string;
'og:description'?: string;
'og:type'?: string;
'og:image'?: string;
'og:image:width'?: string;
'og:image:height'?: string;
'og:site_name'?: string;
ogUrl?: string;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
ogSiteName?: string;
// Article metadata
'article:author'?: string;
'article:published_time'?: string;
'article:modified_time'?: string;
'article:section'?: string;
'article:tag'?: string;
'article:publisher'?: string;
publishedTime?: string;
modifiedTime?: string;
// Twitter metadata
'twitter:site'?: string | boolean | number | null;
'twitter:creator'?: string;
'twitter:card'?: string;
'twitter:image'?: string;
'twitter:dnt'?: string;
'twitter:app:name:iphone'?: string;
'twitter:app:id:iphone'?: string;
'twitter:app:url:iphone'?: string;
'twitter:app:name:ipad'?: string;
'twitter:app:id:ipad'?: string;
'twitter:app:url:ipad'?: string;
'twitter:app:name:googleplay'?: string;
'twitter:app:id:googleplay'?: string;
'twitter:app:url:googleplay'?: string;
// Facebook metadata
'fb:app_id'?: string;
// App links
'al:ios:url'?: string;
'al:ios:app_name'?: string;
'al:ios:app_store_id'?: string;
// Allow for additional properties that might be present
[key: string]: string | number | boolean | null | undefined;
}
export interface FirecrawlScrapeResponse {
success: boolean;
data?: {
markdown?: string;
html?: string;
rawHtml?: string;
screenshot?: string;
links?: string[];
metadata?: ScrapeMetadata;
};
error?: string;
}
export interface FirecrawlScraperConfig {
apiKey?: string;
apiUrl?: string;
formats?: string[];
timeout?: number;
logger?: Logger;
}
export type GetSourcesParams = {
query: string;
date?: DATE_RANGE;
country?: string;
numResults?: number;
safeSearch?: SearchToolConfig['safeSearch'];
images?: boolean;
videos?: boolean;
news?: boolean;
type?: 'search' | 'images' | 'videos' | 'news';
};
/** Serper API */
export interface VideoResult {
title?: string;
link?: string;
snippet?: string;
imageUrl?: string;
duration?: string;
source?: string;
channel?: string;
date?: string;
position?: number;
}
export interface PlaceResult {
position?: number;
name?: string;
address?: string;
latitude?: number;
longitude?: number;
rating?: number;
ratingCount?: number;
category?: string;
identifier?: string;
}
export interface NewsResult {
title?: string;
link?: string;
snippet?: string;
date?: string;
source?: string;
imageUrl?: string;
position?: number;
}
export interface ShoppingResult {
title?: string;
source?: string;
link?: string;
price?: string;
delivery?: string;
imageUrl?: string;
rating?: number;
ratingCount?: number;
offers?: string;
productId?: string;
position?: number;
}
export interface ScholarResult {
title?: string;
link?: string;
publicationInfo?: string;
snippet?: string;
year?: number;
citedBy?: number;
}
export interface ImageResult {
title?: string;
imageUrl?: string;
imageWidth?: number;
imageHeight?: number;
thumbnailUrl?: string;
thumbnailWidth?: number;
thumbnailHeight?: number;
source?: string;
domain?: string;
link?: string;
googleUrl?: string;
position?: number;
}
export interface SerperSearchPayload extends SerperSearchInput {
/**
* Search type/vertical
* Options: "search" (web), "images", "news", "places", "videos"
*/
type?: 'search' | 'images' | 'news' | 'places' | 'videos';
/**
* Starting index for search results pagination (used instead of page)
*/
start?: number;
/**
* Filtering for safe search
* Options: "off", "moderate", "active"
*/
safe?: 'off' | 'moderate' | 'active';
}
export type SerperSearchParameters = Pick<SerperSearchPayload, 'q' | 'type'> & {
engine: 'google';
};
export interface OrganicResult {
position?: number;
title?: string;
link: string;
snippet?: string;
date?: string;
sitelinks?: Array<{
title: string;
link: string;
}>;
}
export interface TopStoryResult {
title?: string;
link: string;
source?: string;
date?: string;
imageUrl?: string;
}
export interface KnowledgeGraphResult {
title?: string;
type?: string;
imageUrl?: string;
description?: string;
descriptionSource?: string;
descriptionLink?: string;
attributes?: Record<string, string>;
website?: string;
}
export interface AnswerBoxResult {
title?: string;
snippet?: string;
snippetHighlighted?: string[];
link?: string;
date?: string;
}
export interface PeopleAlsoAskResult {
question?: string;
snippet?: string;
title?: string;
link?: string;
}
export type RelatedSearches = Array<{ query: string }>;
export interface SerperSearchInput {
/**
* The search query string
*/
q: string;
/**
* Country code for localized results
* Examples: "us", "uk", "ca", "de", etc.
*/
gl?: string;
/**
* Interface language
* Examples: "en", "fr", "de", etc.
*/
hl?: string;
/**
* Number of results to return (up to 100)
*/
num?: number;
/**
* Specific location for contextual results
* Example: "New York, NY"
*/
location?: string;
/**
* Search autocorrection setting
*/
autocorrect?: boolean;
page?: number;
/**
* Date range for search results
* Options: "h" (past hour), "d" (past 24 hours), "w" (past week),
* "m" (past month), "y" (past year)
* `qdr:${DATE_RANGE}`
*/
tbs?: string;
}
export type SerperResultData = {
searchParameters: SerperSearchPayload;
organic?: OrganicResult[];
topStories?: TopStoryResult[];
images?: ImageResult[];
videos?: VideoResult[];
places?: PlaceResult[];
news?: NewsResult[];
shopping?: ShoppingResult[];
peopleAlsoAsk?: PeopleAlsoAskResult[];
relatedSearches?: RelatedSearches;
knowledgeGraph?: KnowledgeGraphResult;
answerBox?: AnswerBoxResult;
credits?: number;
};
/** SearXNG */
export interface SearxNGSearchPayload {
/**
* The search query string
* Supports syntax specific to different search engines
* Example: "site:github.com SearXNG"
*/
q: string;
/**
* Comma-separated list of search categories
* Example: "general,images,news"
*/
categories?: string;
/**
* Comma-separated list of search engines to use
* Example: "google,bing,duckduckgo"
*/
engines?: string;
/**
* Code of the language for search results
* Example: "en", "fr", "de", "es"
*/
language?: string;
/**
* Search page number
* Default: 1
*/
pageno?: number;
/**
* Time range filter for search results
* Options: "day", "month", "year"
*/
time_range?: 'day' | 'month' | 'year';
/**
* Output format of results
* Options: "json", "csv", "rss"
*/
format?: 'json' | 'csv' | 'rss';
/**
* Open search results on new tab
* Options: `0` (off), `1` (on)
*/
results_on_new_tab?: 0 | 1;
/**
* Proxy image results through SearxNG
* Options: true, false
*/
image_proxy?: boolean;
/**
* Service for autocomplete suggestions
* Options: "google", "dbpedia", "duckduckgo", "mwmbl",
* "startpage", "wikipedia", "stract", "swisscows", "qwant"
*/
autocomplete?: string;
/**
* Safe search filtering level
* Options: "0" (off), "1" (moderate), "2" (strict)
*/
safesearch?: 0 | 1 | 2;
/**
* Theme to use for results page
* Default: "simple" (other themes may be available per instance)
*/
theme?: string;
/**
* List of enabled plugins
* Default: "Hash_plugin,Self_Information,Tracker_URL_remover,Ahmia_blacklist"
*/
enabled_plugins?: string;
/**
* List of disabled plugins
*/
disabled_plugins?: string;
/**
* List of enabled engines
*/
enabled_engines?: string;
/**
* List of disabled engines
*/
disabled_engines?: string;
}
export interface SearXNGResult {
title?: string;
url?: string;
content?: string;
publishedDate?: string;
img_src?: string;
}
export type ProcessSourcesFields = {
result: SearchResult;
numElements: number;
query: string;
news: boolean;
proMode: boolean;
onGetHighlights: SearchToolConfig['onGetHighlights'];
};

View file

@ -1,5 +1,15 @@
export const envVarRegex = /^\${(.+)}$/;
/** Extracts the environment variable name from a template literal string */
export function extractVariableName(value: string): string | null {
if (!value) {
return null;
}
const match = value.trim().match(envVarRegex);
return match ? match[1] : null;
}
/** Extracts the value of an environment variable from a string. */
export function extractEnvVariable(value: string) {
if (!value) {

View file

@ -0,0 +1,271 @@
import type {
ScraperTypes,
RerankerTypes,
TCustomConfig,
SearchProviders,
TWebSearchConfig,
} from './config';
import { extractVariableName } from './utils';
import { SearchCategories, SafeSearchTypes } from './config';
import { AuthType } from './schemas';
export function loadWebSearchConfig(
config: TCustomConfig['webSearch'],
): TCustomConfig['webSearch'] {
const serperApiKey = config?.serperApiKey ?? '${SERPER_API_KEY}';
const firecrawlApiKey = config?.firecrawlApiKey ?? '${FIRECRAWL_API_KEY}';
const firecrawlApiUrl = config?.firecrawlApiUrl ?? '${FIRECRAWL_API_URL}';
const jinaApiKey = config?.jinaApiKey ?? '${JINA_API_KEY}';
const cohereApiKey = config?.cohereApiKey ?? '${COHERE_API_KEY}';
const safeSearch = config?.safeSearch ?? SafeSearchTypes.MODERATE;
return {
...config,
safeSearch,
jinaApiKey,
cohereApiKey,
serperApiKey,
firecrawlApiKey,
firecrawlApiUrl,
};
}
export type TWebSearchKeys =
| 'serperApiKey'
| 'firecrawlApiKey'
| 'firecrawlApiUrl'
| 'jinaApiKey'
| 'cohereApiKey';
export type TWebSearchCategories =
| SearchCategories.PROVIDERS
| SearchCategories.SCRAPERS
| SearchCategories.RERANKERS;
export const webSearchAuth = {
providers: {
serper: {
serperApiKey: 1 as const,
},
},
scrapers: {
firecrawl: {
firecrawlApiKey: 1 as const,
/** Optional (0) */
firecrawlApiUrl: 0 as const,
},
},
rerankers: {
jina: { jinaApiKey: 1 as const },
cohere: { cohereApiKey: 1 as const },
},
};
/**
* Extracts all API keys from the webSearchAuth configuration object
*/
export const webSearchKeys: TWebSearchKeys[] = [];
// Iterate through each category (providers, scrapers, rerankers)
for (const category of Object.keys(webSearchAuth)) {
const categoryObj = webSearchAuth[category as TWebSearchCategories];
// Iterate through each service within the category
for (const service of Object.keys(categoryObj)) {
const serviceObj = categoryObj[service as keyof typeof categoryObj];
// Extract the API keys from the service
for (const key of Object.keys(serviceObj)) {
webSearchKeys.push(key as TWebSearchKeys);
}
}
}
export function extractWebSearchEnvVars({
keys,
config,
}: {
keys: TWebSearchKeys[];
config: TCustomConfig['webSearch'] | undefined;
}): string[] {
if (!config) {
return [];
}
const authFields: string[] = [];
const relevantKeys = keys.filter((k) => k in config);
for (const key of relevantKeys) {
const value = config[key];
if (typeof value === 'string') {
const varName = extractVariableName(value);
if (varName) {
authFields.push(varName);
}
}
}
return authFields;
}
/**
* Type for web search authentication result
*/
export interface WebSearchAuthResult {
/** Whether all required categories have at least one authenticated service */
authenticated: boolean;
/** Authentication type (user_provided or system_defined) by category */
authTypes: [TWebSearchCategories, AuthType][];
/** Original authentication values mapped to their respective keys */
authResult: Partial<TWebSearchConfig>;
}
/**
* Loads and verifies web search authentication values
* @param params - Authentication parameters
* @returns Authentication result
*/
export async function loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues,
throwError = true,
}: {
userId: string;
webSearchConfig: TCustomConfig['webSearch'];
loadAuthValues: (params: {
userId: string;
authFields: string[];
optional?: Set<string>;
throwError?: boolean;
}) => Promise<Record<string, string>>;
throwError?: boolean;
}): Promise<WebSearchAuthResult> {
let authenticated = true;
const authResult: Partial<TWebSearchConfig> = {};
/** Type-safe iterator for the category-service combinations */
async function checkAuth<C extends TWebSearchCategories>(
category: C,
): Promise<[boolean, boolean]> {
type ServiceType = keyof (typeof webSearchAuth)[C];
let isUserProvided = false;
// Check if a specific service is specified in the config
let specificService: ServiceType | undefined;
if (category === SearchCategories.PROVIDERS && webSearchConfig?.searchProvider) {
specificService = webSearchConfig.searchProvider as unknown as ServiceType;
} else if (category === SearchCategories.SCRAPERS && webSearchConfig?.scraperType) {
specificService = webSearchConfig.scraperType as unknown as ServiceType;
} else if (category === SearchCategories.RERANKERS && webSearchConfig?.rerankerType) {
specificService = webSearchConfig.rerankerType as unknown as ServiceType;
}
// If a specific service is specified, only check that one
const services = specificService
? [specificService]
: (Object.keys(webSearchAuth[category]) as ServiceType[]);
for (const service of services) {
// Skip if the service doesn't exist in the webSearchAuth config
if (!webSearchAuth[category][service]) {
continue;
}
const serviceConfig = webSearchAuth[category][service];
// Split keys into required and optional
const requiredKeys: TWebSearchKeys[] = [];
const optionalKeys: TWebSearchKeys[] = [];
for (const key in serviceConfig) {
const typedKey = key as TWebSearchKeys;
if (serviceConfig[typedKey as keyof typeof serviceConfig] === 1) {
requiredKeys.push(typedKey);
} else if (serviceConfig[typedKey as keyof typeof serviceConfig] === 0) {
optionalKeys.push(typedKey);
}
}
if (requiredKeys.length === 0) continue;
const requiredAuthFields = extractWebSearchEnvVars({
keys: requiredKeys,
config: webSearchConfig,
});
const optionalAuthFields = extractWebSearchEnvVars({
keys: optionalKeys,
config: webSearchConfig,
});
if (requiredAuthFields.length !== requiredKeys.length) continue;
const allKeys = [...requiredKeys, ...optionalKeys];
const allAuthFields = [...requiredAuthFields, ...optionalAuthFields];
const optionalSet = new Set(optionalAuthFields);
try {
const authValues = await loadAuthValues({
userId,
authFields: allAuthFields,
optional: optionalSet,
throwError,
});
let allFieldsAuthenticated = true;
for (let j = 0; j < allAuthFields.length; j++) {
const field = allAuthFields[j];
const value = authValues[field];
const originalKey = allKeys[j];
if (originalKey) authResult[originalKey] = value;
if (!optionalSet.has(field) && !value) {
allFieldsAuthenticated = false;
break;
}
if (!isUserProvided && process.env[field] !== value) {
isUserProvided = true;
}
}
if (!allFieldsAuthenticated) {
continue;
}
if (category === SearchCategories.PROVIDERS) {
authResult.searchProvider = service as SearchProviders;
} else if (category === SearchCategories.SCRAPERS) {
authResult.scraperType = service as ScraperTypes;
} else if (category === SearchCategories.RERANKERS) {
authResult.rerankerType = service as RerankerTypes;
}
return [true, isUserProvided];
} catch {
continue;
}
}
return [false, isUserProvided];
}
const categories = [
SearchCategories.PROVIDERS,
SearchCategories.SCRAPERS,
SearchCategories.RERANKERS,
] as const;
const authTypes: [TWebSearchCategories, AuthType][] = [];
for (const category of categories) {
const [isCategoryAuthenticated, isUserProvided] = await checkAuth(category);
if (!isCategoryAuthenticated) {
authenticated = false;
authTypes.push([category, AuthType.USER_PROVIDED]);
continue;
}
authTypes.push([category, isUserProvided ? AuthType.USER_PROVIDED : AuthType.SYSTEM_DEFINED]);
}
authResult.safeSearch = webSearchConfig?.safeSearch ?? SafeSearchTypes.MODERATE;
authResult.scraperTimeout = webSearchConfig?.scraperTimeout ?? 7500;
return {
authTypes,
authResult,
authenticated,
};
}

View file

@ -26,6 +26,7 @@ export interface IAgent extends Omit<Document, 'model'> {
conversation_starters?: string[];
tool_resources?: unknown;
projectIds?: Types.ObjectId[];
versions?: Omit<IAgent, 'versions'>[];
}
const agentSchema = new Schema<IAgent>(
@ -115,6 +116,10 @@ const agentSchema = new Schema<IAgent>(
ref: 'Project',
index: true,
},
versions: {
type: [Schema.Types.Mixed],
default: [],
},
},
{
timestamps: true,

View file

@ -26,6 +26,9 @@ export interface IRole extends Document {
[PermissionTypes.RUN_CODE]?: {
[Permissions.USE]?: boolean;
};
[PermissionTypes.WEB_SEARCH]?: {
[Permissions.USE]?: boolean;
};
};
}
@ -54,6 +57,9 @@ const rolePermissionsSchema = new Schema(
[PermissionTypes.RUN_CODE]: {
[Permissions.USE]: { type: Boolean, default: true },
},
[PermissionTypes.WEB_SEARCH]: {
[Permissions.USE]: { type: Boolean, default: true },
},
},
{ _id: false },
);
@ -77,6 +83,7 @@ const roleSchema: Schema<IRole> = new Schema({
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
}),
},
});

View file

@ -13,6 +13,7 @@ export interface IUser extends Document {
googleId?: string;
facebookId?: string;
openidId?: string;
samlId?: string;
ldapId?: string;
githubId?: string;
discordId?: string;
@ -67,7 +68,7 @@ const User = new Schema<IUser>(
},
email: {
type: String,
required: [true, 'can\'t be blank'],
required: [true, "can't be blank"],
lowercase: true,
unique: true,
match: [/\S+@\S+\.\S+/, 'is invalid'],
@ -112,6 +113,11 @@ const User = new Schema<IUser>(
unique: true,
sparse: true,
},
samlId: {
type: String,
unique: true,
sparse: true,
},
ldapId: {
type: String,
unique: true,
@ -160,4 +166,4 @@ const User = new Schema<IUser>(
{ timestamps: true },
);
export default User;
export default User;

View file

@ -567,8 +567,14 @@ export class MCPConnection extends EventEmitter {
return this.connectionState;
}
public isConnected(): boolean {
return this.connectionState === 'connected';
public async isConnected(): Promise<boolean> {
try {
await this.client.ping();
return this.connectionState === 'connected';
} catch (error) {
this.logger?.error(`${this.getLogPrefix()} Ping failed:`, error);
return false;
}
}
public getLastError(): Error | null {

View file

@ -1,5 +1,7 @@
/* MCP */
export * from './manager';
/* Utilities */
export * from './utils';
/* Flow */
export * from './flow/manager';
/* types */

View file

@ -71,7 +71,7 @@ export class MCPManager {
const connectionAttempt = this.initializeServer(connection, `[MCP][${serverName}]`);
await Promise.race([connectionAttempt, connectionTimeout]);
if (connection.isConnected()) {
if (await connection.isConnected()) {
initializedServers.add(i);
this.connections.set(serverName, connection); // Store in app-level map
@ -135,7 +135,7 @@ export class MCPManager {
while (attempts < maxAttempts) {
try {
await connection.connect();
if (connection.isConnected()) {
if (await connection.isConnected()) {
return;
}
throw new Error('Connection attempt succeeded but status is not connected');
@ -200,7 +200,7 @@ export class MCPManager {
}
connection = undefined; // Force creation of a new connection
} else if (connection) {
if (connection.isConnected()) {
if (await connection.isConnected()) {
this.logger.debug(`[MCP][User: ${userId}][${serverName}] Reusing active connection`);
// Update timestamp on reuse
this.updateUserLastActivity(userId);
@ -244,7 +244,7 @@ export class MCPManager {
);
await Promise.race([connectionAttempt, connectionTimeout]);
if (!connection.isConnected()) {
if (!(await connection.isConnected())) {
throw new Error('Failed to establish connection after initialization attempt.');
}
@ -342,7 +342,7 @@ export class MCPManager {
public async mapAvailableTools(availableTools: t.LCAvailableTools): Promise<void> {
for (const [serverName, connection] of this.connections.entries()) {
try {
if (connection.isConnected() !== true) {
if ((await connection.isConnected()) !== true) {
this.logger.warn(
`[MCP][${serverName}] Connection not established. Skipping tool mapping.`,
);
@ -375,7 +375,7 @@ export class MCPManager {
for (const [serverName, connection] of this.connections.entries()) {
try {
if (connection.isConnected() !== true) {
if ((await connection.isConnected()) !== true) {
this.logger.warn(
`[MCP][${serverName}] Connection not established. Skipping manifest loading.`,
);
@ -443,7 +443,7 @@ export class MCPManager {
}
}
if (!connection.isConnected()) {
if (!(await connection.isConnected())) {
// This might happen if getUserConnection failed silently or app connection dropped
throw new McpError(
ErrorCode.InternalError, // Use InternalError for connection issues

View file

@ -1,5 +1,13 @@
import type * as t from './types/mcp';
const RECOGNIZED_PROVIDERS = new Set(['google', 'anthropic', 'openai', 'openrouter', 'xai', 'deepseek', 'ollama']);
const RECOGNIZED_PROVIDERS = new Set([
'google',
'anthropic',
'openai',
'openrouter',
'xai',
'deepseek',
'ollama',
]);
const CONTENT_ARRAY_PROVIDERS = new Set(['google', 'anthropic', 'openai']);
const imageFormatters: Record<string, undefined | t.ImageFormatter> = {
@ -49,6 +57,12 @@ function parseAsString(result: t.MCPToolCallResponse): string {
if (item.resource.uri) {
resourceText.push(`Resource URI: ${item.resource.uri}`);
}
if (item.resource.name) {
resourceText.push(`Resource: ${item.resource.name}`);
}
if (item.resource.description) {
resourceText.push(`Description: ${item.resource.description}`);
}
if (item.resource.mimeType != null && item.resource.mimeType) {
resourceText.push(`Type: ${item.resource.mimeType}`);
}
@ -133,6 +147,12 @@ export function formatToolContent(
if (item.resource.uri.length) {
resourceText.push(`Resource URI: ${item.resource.uri}`);
}
if (item.resource.name) {
resourceText.push(`Resource: ${item.resource.name}`);
}
if (item.resource.description) {
resourceText.push(`Description: ${item.resource.description}`);
}
if (item.resource.mimeType != null && item.resource.mimeType) {
resourceText.push(`Type: ${item.resource.mimeType}`);
}

View file

@ -0,0 +1,28 @@
import { normalizeServerName } from './utils';
describe('normalizeServerName', () => {
it('should not modify server names that already match the pattern', () => {
const result = normalizeServerName('valid-server_name.123');
expect(result).toBe('valid-server_name.123');
});
it('should normalize server names with non-ASCII characters', () => {
const result = normalizeServerName('我的服务');
// Should generate a fallback name with a hash
expect(result).toMatch(/^server_\d+$/);
expect(result).toMatch(/^[a-zA-Z0-9_.-]+$/);
});
it('should normalize server names with special characters', () => {
const result = normalizeServerName('server@name!');
// The actual result doesn't have the trailing underscore after trimming
expect(result).toBe('server_name');
expect(result).toMatch(/^[a-zA-Z0-9_.-]+$/);
});
it('should trim leading and trailing underscores', () => {
const result = normalizeServerName('!server-name!');
expect(result).toBe('server-name');
expect(result).toMatch(/^[a-zA-Z0-9_.-]+$/);
});
});

30
packages/mcp/src/utils.ts Normal file
View file

@ -0,0 +1,30 @@
/**
* Normalizes a server name to match the pattern ^[a-zA-Z0-9_.-]+$
* This is required for Azure OpenAI models with Tool Calling
*/
export function normalizeServerName(serverName: string): string {
// Check if the server name already matches the pattern
if (/^[a-zA-Z0-9_.-]+$/.test(serverName)) {
return serverName;
}
/** Replace non-matching characters with underscores.
This preserves the general structure while ensuring compatibility.
Trims leading/trailing underscores
*/
const normalized = serverName.replace(/[^a-zA-Z0-9_.-]/g, '_').replace(/^_+|_+$/g, '');
// If the result is empty (e.g., all characters were non-ASCII and got trimmed),
// generate a fallback name to ensure we always have a valid function name
if (!normalized) {
/** Hash of the original name to ensure uniqueness */
let hash = 0;
for (let i = 0; i < serverName.length; i++) {
hash = (hash << 5) - hash + serverName.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
return `server_${Math.abs(hash)}`;
}
return normalized;
}