Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@librechat/agents",
"version": "3.6.8",
"version": "3.6.9",
"reova": {
"enabled": true,
"endpoint": "https://telemetry.reo.dev/data"
Expand Down
33 changes: 25 additions & 8 deletions src/tools/search/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { createTavilyAPI } from './tavily-search';
import { createCrwAPI } from './crw-search';
import { BaseReranker } from './rerankers';

/** Engines queried when `searxngSearchOptions.engines` is not configured. */
const DEFAULT_SEARXNG_ENGINES = 'google,bing,duckduckgo';

const chunker = {
cleanText: (text: string): string => {
if (!text) return '';
Expand Down Expand Up @@ -284,15 +287,20 @@ const createSerperAPI = (
const createSearXNGAPI = (
instanceUrl?: string,
apiKey?: string,
agents?: t.HttpAgentConfig
options?: t.SearxNGSearchOptions
): {
getSources: (params: t.GetSourcesParams) => Promise<t.SearchResult>;
} => {
const engines = options?.engines?.trim();
const language = options?.language?.trim();
const config = {
instanceUrl: instanceUrl ?? process.env.SEARXNG_INSTANCE_URL,
apiKey: apiKey ?? process.env.SEARXNG_API_KEY,
defaultLocation: 'all',
timeout: 10000,
engines:
engines != null && engines !== '' ? engines : DEFAULT_SEARXNG_ENGINES,
language: language != null && language !== '' ? language : 'all',
timeRange: options?.timeRange,
timeout: options?.timeout ?? 10000,
};

if (config.instanceUrl == null || config.instanceUrl === '') {
Expand Down Expand Up @@ -336,11 +344,15 @@ const createSearXNGAPI = (
format: 'json',
pageno: 1,
categories: category,
language: 'all',
language: config.language,
safesearch: safeSearch,
engines: 'google,bing,duckduckgo',
engines: config.engines,
};

if (config.timeRange != null) {
params.time_range = config.timeRange;
}

const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
Expand All @@ -353,8 +365,8 @@ const createSearXNGAPI = (
headers,
params,
timeout: config.timeout,
httpAgent: agents?.httpAgent,
httpsAgent: agents?.httpsAgent,
httpAgent: options?.httpAgent,
httpsAgent: options?.httpsAgent,
});

const data = response.data;
Expand Down Expand Up @@ -490,6 +502,7 @@ export const createSearchAPI = (
serperApiKey,
searxngInstanceUrl,
searxngApiKey,
searxngSearchOptions,
tavilyApiKey,
tavilySearchUrl,
tavilySearchOptions,
Expand All @@ -508,7 +521,11 @@ export const createSearchAPI = (
if (searchProvider.toLowerCase() === 'serper') {
return createSerperAPI(serperApiKey, agents);
} else if (searchProvider.toLowerCase() === 'searxng') {
return createSearXNGAPI(searxngInstanceUrl, searxngApiKey, agents);
return createSearXNGAPI(searxngInstanceUrl, searxngApiKey, {
...searxngSearchOptions,
httpAgent: httpAgent ?? searxngSearchOptions?.httpAgent,
httpsAgent: httpsAgent ?? searxngSearchOptions?.httpsAgent,
});
} else if (searchProvider.toLowerCase() === 'tavily') {
return createTavilyAPI(tavilyApiKey, tavilySearchUrl, {
...tavilySearchOptions,
Expand Down
176 changes: 176 additions & 0 deletions src/tools/search/searxng.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import axios from 'axios';
import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https';
import type * as t from './types';
import { createSearchAPI } from './search';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

const INSTANCE_URL = 'https://searxng.example.com';

const emptyResponse = { data: { results: [] } };

/** The params object handed to `axios.get` for the most recent call. */
const lastParams = (): t.SearxNGSearchPayload =>
mockedAxios.get.mock.calls[0][1]?.params as t.SearxNGSearchPayload;

/** The full request config handed to `axios.get` for the most recent call. */
const lastRequestConfig = () => mockedAxios.get.mock.calls[0][1];

describe('SearXNG search options', () => {
beforeEach(() => {
jest.clearAllMocks();
delete process.env.SEARXNG_INSTANCE_URL;
delete process.env.SEARXNG_API_KEY;
mockedAxios.get.mockResolvedValue(emptyResponse);
});

describe('defaults when searxngSearchOptions is absent', () => {
it('sends the original engines, language, and timeout, and no time_range', async () => {
const searchAPI = createSearchAPI({
searchProvider: 'searxng',
searxngInstanceUrl: INSTANCE_URL,
});
await searchAPI.getSources({ query: 'typescript' });

expect(lastParams()).toEqual({
q: 'typescript',
format: 'json',
pageno: 1,
categories: 'general',
language: 'all',
safesearch: undefined,
engines: 'google,bing,duckduckgo',
});
expect(lastParams()).not.toHaveProperty('time_range');
expect(lastRequestConfig()?.timeout).toBe(10000);
});

it('sends the same defaults when an empty options object is supplied', async () => {
const searchAPI = createSearchAPI({
searchProvider: 'searxng',
searxngInstanceUrl: INSTANCE_URL,
searxngSearchOptions: {},
});
await searchAPI.getSources({ query: 'typescript' });

const params = lastParams();
expect(params.engines).toBe('google,bing,duckduckgo');
expect(params.language).toBe('all');
expect(params).not.toHaveProperty('time_range');
expect(lastRequestConfig()?.timeout).toBe(10000);
});
});

describe('overrides', () => {
it('applies engines, language, and timeout when provided', async () => {
const searchAPI = createSearchAPI({
searchProvider: 'searxng',
searxngInstanceUrl: INSTANCE_URL,
searxngSearchOptions: {
engines: 'startpage,qwant',
language: 'de',
timeout: 45000,
},
});
await searchAPI.getSources({ query: 'typescript' });

const params = lastParams();
expect(params.engines).toBe('startpage,qwant');
expect(params.language).toBe('de');
expect(lastRequestConfig()?.timeout).toBe(45000);
});

it('omits time_range when unset and sends it when set', async () => {
const withoutRange = createSearchAPI({
searchProvider: 'searxng',
searxngInstanceUrl: INSTANCE_URL,
searxngSearchOptions: { engines: 'startpage' },
});
await withoutRange.getSources({ query: 'typescript' });
expect(lastParams()).not.toHaveProperty('time_range');

jest.clearAllMocks();
mockedAxios.get.mockResolvedValue(emptyResponse);

const withRange = createSearchAPI({
searchProvider: 'searxng',
searxngInstanceUrl: INSTANCE_URL,
searxngSearchOptions: { timeRange: 'month' },
});
await withRange.getSources({ query: 'typescript' });
expect(lastParams().time_range).toBe('month');
});

it('falls back to the defaults for blank engines and language', async () => {
const searchAPI = createSearchAPI({
searchProvider: 'searxng',
searxngInstanceUrl: INSTANCE_URL,
searxngSearchOptions: { engines: ' ', language: '' },
});
await searchAPI.getSources({ query: 'typescript' });

const params = lastParams();
expect(params.engines).toBe('google,bing,duckduckgo');
expect(params.language).toBe('all');
});
});

describe('fields the options block must not take over', () => {
it.each([
['general', undefined],
['images', 'images'],
['videos', 'videos'],
['news', 'news'],
] as const)(
'derives categories=%s from type even with options supplied',
async (expectedCategory, type) => {
const searchAPI = createSearchAPI({
searchProvider: 'searxng',
searxngInstanceUrl: INSTANCE_URL,
searxngSearchOptions: {
engines: 'startpage,qwant',
language: 'fr',
timeRange: 'day',
},
});
await searchAPI.getSources({ query: 'typescript', type });

const params = lastParams();
expect(params.categories).toBe(expectedCategory);
expect(params.engines).toBe('startpage,qwant');
}
);

it('sources safesearch from the top-level safeSearch argument', async () => {
const searchAPI = createSearchAPI({
searchProvider: 'searxng',
searxngInstanceUrl: INSTANCE_URL,
searxngSearchOptions: { engines: 'startpage' },
});
await searchAPI.getSources({ query: 'typescript', safeSearch: 2 });

expect(lastParams().safesearch).toBe(2);
});
});

it('still threads http agents supplied alongside the options block', async () => {
const httpAgent = new HttpAgent();
const httpsAgent = new HttpsAgent();

const searchAPI = createSearchAPI({
searchProvider: 'searxng',
searxngInstanceUrl: INSTANCE_URL,
searxngSearchOptions: { engines: 'startpage' },
httpAgent,
httpsAgent,
});
await searchAPI.getSources({ query: 'typescript' });

expect(lastRequestConfig()).toEqual(
expect.objectContaining({ httpAgent, httpsAgent })
);
expect(lastParams().engines).toBe('startpage');
});
});
2 changes: 2 additions & 0 deletions src/tools/search/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ export const createSearchTool = (
serperApiKey,
searxngInstanceUrl,
searxngApiKey,
searxngSearchOptions,
tavilyApiKey,
tavilySearchUrl,
tavilyExtractUrl,
Expand Down Expand Up @@ -494,6 +495,7 @@ export const createSearchTool = (
serperApiKey,
searxngInstanceUrl,
searxngApiKey,
searxngSearchOptions,
tavilyApiKey,
tavilySearchUrl,
tavilySearchOptions: effectiveTavilySearchOptions,
Expand Down
16 changes: 16 additions & 0 deletions src/tools/search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,27 @@ export interface CrwSearchResponse {
error_code?: string;
}

export interface SearxNGSearchOptions extends HttpAgentConfig {
/**
* Comma-separated list of engines to query, e.g. "google,bing,startpage".
* Defaults to "google,bing,duckduckgo". Engines not enabled on the target
* instance are ignored by SearXNG.
*/
engines?: string;
/** Code of the language for search results. Defaults to "all". */
language?: string;
/** Time range filter, sent to SearXNG as `time_range`. Omitted when unset. */
timeRange?: SearxNGSearchPayload['time_range'];
/** HTTP request timeout in ms. Defaults to 10000. */
timeout?: number;
}

export interface SearchConfig extends HttpAgentConfig {
searchProvider?: SearchProvider;
serperApiKey?: string;
searxngInstanceUrl?: string;
searxngApiKey?: string;
searxngSearchOptions?: SearxNGSearchOptions;
tavilyApiKey?: string;
tavilySearchUrl?: string;
tavilyExtractUrl?: string;
Expand Down
Loading