🔍 feat: add Tavily as Search and Scraper Provider (#12581)

* feat: add Tavily integration as search provider and scraper provider

* chore:update tavily web search parameters

* chore:tavily paramer update

* chore:update data-schemas test for tavily

* fix: allow Tavily string option modes

* fix: align Tavily config options

* fix: scope Tavily scraper timeout

* fix: use resolved scraper provider timeout

* fix: widen Tavily search provider types

* fix: harden Tavily web search config

* fix: cap Tavily option timeouts

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Yashwanth Alapati 2026-05-03 22:29:13 -04:00 • committed by GitHub
parent d6d70eeb26
commit 3da1d8c961
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 495 additions and 5 deletions

View file

@ -730,6 +730,130 @@ describe('web.ts', () => {
expect(providerCalls.length).toBe(1);
});
it('should authenticate Tavily as a search provider and pass options through', async () => {
const webSearchConfig: TCustomConfig['webSearch'] = {
tavilyApiKey: '${TAVILY_API_KEY}',
tavilySearchUrl: '${TAVILY_SEARCH_URL}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
safeSearch: SafeSearchTypes.MODERATE,
searchProvider: 'tavily' as SearchProviders,
scraperProvider: 'firecrawl' as ScraperProviders,
rerankerType: 'none' as RerankerTypes,
tavilySearchOptions: {
searchDepth: 'advanced',
maxResults: 5,
includeRawContent: 'markdown',
},
};
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field: string) => {
if (field === 'TAVILY_API_KEY') {
result[field] = 'tavily-api-key';
} else if (field === 'TAVILY_SEARCH_URL') {
result[field] = 'https://api.tavily.com/search';
} else if (field === 'FIRECRAWL_API_URL') {
result[field] = 'https://api.firecrawl.dev';
} else {
result[field] = '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('tavily');
expect(result.authResult.tavilyApiKey).toBe('tavily-api-key');
expect(result.authResult.tavilySearchUrl).toBe('https://api.tavily.com/search');
expect(result.authResult.tavilySearchOptions).toEqual(webSearchConfig.tavilySearchOptions);
});
it('should fail authentication when Tavily search API key is missing', async () => {
const webSearchConfig: TCustomConfig['webSearch'] = {
tavilyApiKey: '${TAVILY_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
safeSearch: SafeSearchTypes.MODERATE,
searchProvider: 'tavily' as SearchProviders,
scraperProvider: 'firecrawl' as ScraperProviders,
rerankerType: 'none' as RerankerTypes,
};
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field: string) => {
if (field !== 'TAVILY_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);
const providersAuthType = result.authTypes.find(
([category]) => category === 'providers',
)?.[1];
expect(providersAuthType).toBe(AuthType.USER_PROVIDED);
});
it('should authenticate Tavily as both search provider and scraper with a shared key', async () => {
const webSearchConfig: TCustomConfig['webSearch'] = {
tavilyApiKey: '${TAVILY_API_KEY}',
tavilySearchUrl: '${TAVILY_SEARCH_URL}',
tavilyExtractUrl: '${TAVILY_EXTRACT_URL}',
safeSearch: SafeSearchTypes.MODERATE,
searchProvider: 'tavily' as SearchProviders,
scraperProvider: 'tavily' as ScraperProviders,
rerankerType: 'none' as RerankerTypes,
tavilyScraperOptions: {
extractDepth: 'advanced',
timeout: 20000,
},
};
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field: string) => {
if (field === 'TAVILY_API_KEY') {
result[field] = 'tavily-api-key';
} else if (field === 'TAVILY_SEARCH_URL') {
result[field] = 'https://api.tavily.com/search';
} else if (field === 'TAVILY_EXTRACT_URL') {
result[field] = 'https://api.tavily.com/extract';
}
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authenticated).toBe(true);
expect(result.authResult.searchProvider).toBe('tavily');
expect(result.authResult.scraperProvider).toBe('tavily');
expect(result.authResult.tavilyApiKey).toBe('tavily-api-key');
expect(result.authResult.tavilyScraperOptions).toEqual(webSearchConfig.tavilyScraperOptions);
expect(result.authResult.scraperTimeout).toBe(20000);
});
it('should only check the specified scraperProvider', async () => {
// Initialize a webSearchConfig with a specific scraperProvider
const webSearchConfig: TCustomConfig['webSearch'] = {
@ -1165,6 +1289,76 @@ describe('web.ts', () => {
});
});
it('should not use tavilyScraperOptions.timeout for firecrawl scraper', async () => {
const webSearchConfig = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
tavilyApiKey: '${TAVILY_API_KEY}',
jinaApiKey: '${JINA_API_KEY}',
jinaApiUrl: '${JINA_API_URL}',
safeSearch: SafeSearchTypes.MODERATE,
scraperProvider: 'firecrawl' as ScraperProviders,
tavilyScraperOptions: {
timeout: 22000,
},
} as TCustomConfig['webSearch'];
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field: string) => {
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.scraperTimeout).toBe(7500);
});
it('should use tavilyScraperOptions.timeout for tavily scraper', async () => {
const webSearchConfig = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
tavilyApiKey: '${TAVILY_API_KEY}',
jinaApiKey: '${JINA_API_KEY}',
jinaApiUrl: '${JINA_API_URL}',
safeSearch: SafeSearchTypes.MODERATE,
scraperProvider: 'tavily' as ScraperProviders,
firecrawlOptions: {
timeout: 12000,
},
tavilyScraperOptions: {
timeout: 22000,
},
} as TCustomConfig['webSearch'];
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field: string) => {
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.authResult.scraperTimeout).toBe(22000);
});
it('should handle firecrawlOptions.formats when only formats is provided', async () => {
// Initialize a webSearchConfig with only firecrawlOptions.formats
const webSearchConfig = {
@ -1359,6 +1553,81 @@ describe('web.ts', () => {
expect(scrapersAuth).toBe(AuthType.USER_PROVIDED);
});
it('should block user-provided tavilySearchUrl targeting localhost', async () => {
mockIsSSRFTarget.mockImplementation((hostname: string) => hostname === 'localhost');
const webSearchConfig: TCustomConfig['webSearch'] = {
tavilyApiKey: '${TAVILY_API_KEY}',
tavilySearchUrl: '${TAVILY_SEARCH_URL}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
safeSearch: SafeSearchTypes.MODERATE,
searchProvider: 'tavily' as SearchProviders,
rerankerType: 'none' as RerankerTypes,
};
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field: string) => {
if (field === 'TAVILY_SEARCH_URL') {
result[field] = 'http://localhost:8080/search';
} else {
result[field] = 'test-api-key';
}
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authResult.tavilySearchUrl).toBeUndefined();
expect(result.authResult.searchProvider).toBe('tavily');
expect(result.authenticated).toBe(true);
expect(mockIsSSRFTarget).toHaveBeenCalledWith('localhost');
});
it('should block user-provided tavilyExtractUrl resolving to private IP', async () => {
mockResolveHostnameSSRF.mockImplementation((hostname: string) =>
Promise.resolve(hostname === 'extract.internal-service.com'),
);
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
tavilyApiKey: '${TAVILY_API_KEY}',
tavilyExtractUrl: '${TAVILY_EXTRACT_URL}',
safeSearch: SafeSearchTypes.MODERATE,
scraperProvider: 'tavily' as ScraperProviders,
rerankerType: 'none' as RerankerTypes,
};
mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field: string) => {
if (field === 'TAVILY_EXTRACT_URL') {
result[field] = 'https://extract.internal-service.com/extract';
} else {
result[field] = 'test-api-key';
}
});
return Promise.resolve(result);
});
const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});
expect(result.authResult.tavilyExtractUrl).toBeUndefined();
expect(result.authResult.scraperProvider).toBe('tavily');
expect(result.authenticated).toBe(true);
const scrapersAuth = result.authTypes.find(([c]) => c === 'scrapers')?.[1];
expect(scrapersAuth).toBe(AuthType.USER_PROVIDED);
});
it('should block user-provided searxngInstanceUrl targeting metadata endpoint', async () => {
mockIsSSRFTarget.mockImplementation((hostname: string) => hostname === '169.254.169.254');

View file

@ -23,6 +23,8 @@ const WEB_SEARCH_URL_KEYS = new Set<TWebSearchKeys>([
'searxngInstanceUrl',
'firecrawlApiUrl',
'jinaApiUrl',
'tavilySearchUrl',
'tavilyExtractUrl',
]);
/**
@ -245,10 +247,20 @@ export async function loadWebSearchAuth({
authTypes.push([category, isUserProvided ? AuthType.USER_PROVIDED : AuthType.SYSTEM_DEFINED]);
}
const scraperProvider =
authResult.scraperProvider ?? webSearchConfig?.scraperProvider ?? 'firecrawl';
let scraperOptionsTimeout: number | undefined;
if (scraperProvider === 'tavily') {
scraperOptionsTimeout = webSearchConfig?.tavilyScraperOptions?.timeout;
} else if (scraperProvider === 'firecrawl') {
scraperOptionsTimeout = webSearchConfig?.firecrawlOptions?.timeout;
}
authResult.safeSearch = webSearchConfig?.safeSearch ?? SafeSearchTypes.MODERATE;
authResult.scraperTimeout =
webSearchConfig?.scraperTimeout ?? webSearchConfig?.firecrawlOptions?.timeout ?? 7500;
authResult.scraperTimeout = webSearchConfig?.scraperTimeout ?? scraperOptionsTimeout ?? 7500;
authResult.firecrawlOptions = webSearchConfig?.firecrawlOptions;
authResult.tavilySearchOptions = webSearchConfig?.tavilySearchOptions;
authResult.tavilyScraperOptions = webSearchConfig?.tavilyScraperOptions;
return {
authTypes,