Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ describe('ApiKeyDialog', () => {
// Switch to Cohere
fireEvent.click(screen.getByText('com_ui_web_search_reranker_cohere'));
expect(screen.getByPlaceholderText('com_ui_web_search_cohere_key')).toBeInTheDocument();
expect(screen.getByPlaceholderText('com_ui_web_search_cohere_url')).toBeInTheDocument();
});

it('restores per-user provider and scraper selections', () => {
Expand Down Expand Up @@ -100,6 +101,7 @@ describe('ApiKeyDialog', () => {
expect(screen.getByPlaceholderText('com_ui_web_search_keenable_url')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('com_ui_web_search_jina_key')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('com_ui_web_search_cohere_key')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('com_ui_web_search_cohere_url')).not.toBeInTheDocument();
});

it('shows static text for provider and only provider input if provider is set', () => {
Expand All @@ -126,6 +128,7 @@ describe('ApiKeyDialog', () => {
render(<ApiKeyDialog {...defaultProps} />);
expect(screen.getByPlaceholderText('com_ui_web_search_jina_key')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('com_ui_web_search_cohere_key')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('com_ui_web_search_cohere_url')).not.toBeInTheDocument();
});

it('shows only Cohere reranker field if rerankerType is set to cohere', () => {
Expand All @@ -134,7 +137,9 @@ describe('ApiKeyDialog', () => {
});
render(<ApiKeyDialog {...defaultProps} />);
expect(screen.getByPlaceholderText('com_ui_web_search_cohere_key')).toBeInTheDocument();
expect(screen.getByPlaceholderText('com_ui_web_search_cohere_url')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('com_ui_web_search_jina_key')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('com_ui_web_search_jina_url')).not.toBeInTheDocument();
});

it('shows documentation link for the visible reranker', () => {
Expand All @@ -153,6 +158,9 @@ describe('ApiKeyDialog', () => {
'underline',
'hover:text-link-hover',
);
expect(
screen.getByRole('link', { name: 'com_ui_web_search_reranker_cohere_url_help' }),
).toHaveClass('text-link', 'underline', 'hover:text-link-hover');
});

it('does not render provider section if SYSTEM_DEFINED', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,14 @@ export default function ApiKeyDialog({
text: localize('com_ui_web_search_reranker_cohere_key'),
},
},
cohereApiUrl: {
placeholder: localize('com_ui_web_search_cohere_url'),
type: 'text' as const,
link: {
url: 'https://docs.cohere.com/reference/rerank',
text: localize('com_ui_web_search_reranker_cohere_url_help'),
},
},
},
},
];
Expand Down
14 changes: 14 additions & 0 deletions client/src/hooks/Plugins/__tests__/useAuthSearchTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const emptyForm = (): SearchApiKeyFormData => ({
jinaApiKey: '',
jinaApiUrl: '',
cohereApiKey: '',
cohereApiUrl: '',
});

describe('useAuthSearchTool', () => {
Expand All @@ -45,6 +46,19 @@ describe('useAuthSearchTool', () => {
});
});

it('forwards an explicitly empty Cohere URL as a clear operation', () => {
const { result } = renderHook(() => useAuthSearchTool());

act(() => result.current.installTool(emptyForm(), { cohereApiUrl: true }));

expect(mockMutate).toHaveBeenCalledWith({
pluginKey: Tools.web_search,
action: 'install',
auth: { cohereApiUrl: '' },
isEntityTool: true,
});
});

it('omits a pristine empty Keenable URL', () => {
const { result } = renderHook(() => useAuthSearchTool());

Expand Down
2 changes: 2 additions & 0 deletions client/src/hooks/Plugins/useAuthSearchTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type SearchApiKeyFormData = {
jinaApiKey: string;
jinaApiUrl: string;
cohereApiKey: string;
cohereApiUrl: string;
};

export type SearchApiKeyDirtyFields = Partial<Record<keyof SearchApiKeyFormData, boolean>>;
Expand Down Expand Up @@ -82,6 +83,7 @@ const useAuthSearchTool = (options?: { isEntityTool: boolean }) => {
jinaApiKey: data.jinaApiKey,
jinaApiUrl: data.jinaApiUrl,
cohereApiKey: data.cohereApiKey,
cohereApiUrl: data.cohereApiUrl,
}).reduce(
(acc, [key, value]) => {
const wasExplicitlyCleared =
Expand Down
2 changes: 2 additions & 0 deletions client/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -3533,6 +3533,7 @@
"com_ui_native_web_search_denied": "Your role does not allow web search, including provider-native search. Ask an administrator to enable your Web Search permission.",
"com_ui_web_search": "Web Search",
"com_ui_web_search_cohere_key": "Enter Cohere API Key",
"com_ui_web_search_cohere_url": "Cohere API URL (optional)",
"com_ui_web_search_firecrawl_url": "Firecrawl API URL (optional)",
"com_ui_web_search_jina_key": "Enter Jina API Key",
"com_ui_web_search_jina_url": "Jina API URL (optional)",
Expand All @@ -3550,6 +3551,7 @@
"com_ui_web_search_reranker": "Reranker",
"com_ui_web_search_reranker_cohere": "Cohere",
"com_ui_web_search_reranker_cohere_key": "Get your Cohere API key",
"com_ui_web_search_reranker_cohere_url_help": "Learn about Cohere Rerank API",
"com_ui_web_search_reranker_jina": "Jina AI",
"com_ui_web_search_reranker_none": "None",
"com_ui_web_search_reranker_jina_key": "Get your Jina API key",
Expand Down
1 change: 1 addition & 0 deletions librechat.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,7 @@ endpoints:
# jinaApiUrl: '${JINA_API_URL}' # Custom Jina API URL (optional, defaults to https://api.jina.ai/v1/rerank)
# # Other rerankers
# cohereApiKey: '${COHERE_API_KEY}'
# cohereApiUrl: '${COHERE_API_URL}' # Custom Cohere-compatible API URL (optional, defaults to https://api.cohere.com/v2/rerank)
# # Search providers
# serperApiKey: '${SERPER_API_KEY}'
# searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}'
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/app/AppService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ describe('AppService', () => {
jinaApiKey: '${JINA_API_KEY}',
jinaApiUrl: '${JINA_API_URL}',
cohereApiKey: '${COHERE_API_KEY}',
cohereApiUrl: '${COHERE_API_URL}',
serperApiKey: '${SERPER_API_KEY}',
searxngApiKey: '${SEARXNG_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
Expand Down
10 changes: 10 additions & 0 deletions packages/api/src/web/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ describe('resolveWebSearchSSRFAgents', () => {
expect(() => connect(httpAgent, '10.11.12.13', 3128)).not.toThrow();
});

it('derives proxy exemptions from a custom Cohere API URL', () => {
process.env.HTTP_PROXY = 'http://10.11.12.16:3128';

const { httpAgent } = resolveWebSearchSSRFAgents({
cohereApiUrl: 'http://cohere.internal:8080',
});

expect(() => connect(httpAgent, '10.11.12.16', 3128)).not.toThrow();
});

it('derives proxy exemptions from the Keenable fetch URL environment override', () => {
process.env.HTTP_PROXY = 'http://10.11.12.14:3128';
process.env.KEENABLE_FETCH_URL = 'http://keenable-fetch.internal:8080';
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/web/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const WEB_SEARCH_URL_KEYS = [
'searxngInstanceUrl',
'firecrawlApiUrl',
'jinaApiUrl',
'cohereApiUrl',
'tavilySearchUrl',
'tavilyExtractUrl',
'keenableApiUrl',
Expand Down
66 changes: 66 additions & 0 deletions packages/api/src/web/web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2781,6 +2781,40 @@ describe('web.ts', () => {
expect(mockIsSSRFTarget).toHaveBeenCalledWith('localhost', undefined, '8080');
});

it('should block user-provided cohereApiUrl targeting localhost', async () => {
mockIsSSRFTarget.mockImplementation((hostname: string) => hostname === 'localhost');

const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
cohereApiUrl: '${COHERE_API_URL}',
safeSearch: SafeSearchTypes.MODERATE,
rerankerType: 'cohere' as RerankerTypes,
};

mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field: string) => {
if (field === 'COHERE_API_URL') {
result[field] = 'http://localhost:8080/v2/rerank';
} else {
result[field] = 'test-api-key';
}
});
return Promise.resolve(result);
});

const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});

expect(result.authResult.cohereApiUrl).toBeUndefined();
expect(mockIsSSRFTarget).toHaveBeenCalledWith('localhost', undefined, '8080');
});

it('should block user-provided firecrawlApiUrl resolving to private IP', async () => {
mockResolveHostnameSSRF.mockImplementation((hostname: string) =>
Promise.resolve(hostname === 'evil.internal-service.com'),
Expand Down Expand Up @@ -3176,6 +3210,38 @@ describe('web.ts', () => {
expect(result.authenticated).toBe(true);
});

it('should allow a self-hosted cohereApiUrl (e.g. a LiteLLM proxy)', async () => {
const webSearchConfig: TCustomConfig['webSearch'] = {
serperApiKey: '${SERPER_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
cohereApiUrl: '${COHERE_API_URL}',
safeSearch: SafeSearchTypes.MODERATE,
rerankerType: 'cohere' as RerankerTypes,
};

mockLoadAuthValues.mockImplementation(({ authFields }) => {
const result: Record<string, string> = {};
authFields.forEach((field: string) => {
if (field === 'COHERE_API_URL') {
result[field] = 'https://litellm.internal/v2/rerank';
} else {
result[field] = 'test-api-key';
}
});
return Promise.resolve(result);
});

const result = await loadWebSearchAuth({
userId,
webSearchConfig,
loadAuthValues: mockLoadAuthValues,
});

expect(result.authResult.cohereApiUrl).toBe('https://litellm.internal/v2/rerank');
expect(result.authenticated).toBe(true);
});

it('should fail required URL field and mark category unauthenticated', async () => {
mockIsSSRFTarget.mockImplementation((hostname: string) => hostname === '127.0.0.1');

Expand Down
1 change: 1 addition & 0 deletions packages/api/src/web/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const USER_PROVIDED_URL_KEYS = new Map<TWebSearchKeys, TWebSearchKeys>([
['searxngInstanceUrl', 'searxngApiKey'],
['firecrawlApiUrl', 'firecrawlApiKey'],
['jinaApiUrl', 'jinaApiKey'],
['cohereApiUrl', 'cohereApiKey'],
]);

/**
Expand Down
1 change: 1 addition & 0 deletions packages/data-provider/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3551,6 +3551,7 @@ export const webSearchSchema = z.object({
jinaApiUrl: z.string().optional().default('${JINA_API_URL}'),
cohereApiKey: z.string().optional().default('${COHERE_API_KEY}'),
cohereApiKeyPreview: apiKeyPreviewSchema,
cohereApiUrl: z.string().optional().default('${COHERE_API_URL}'),
Comment thread
busla marked this conversation as resolved.
searchProvider: z.nativeEnum(SearchProviders).optional(),
scraperProvider: z.nativeEnum(ScraperProviders).optional(),
rerankerType: z.nativeEnum(RerankerTypes).optional(),
Expand Down
4 changes: 4 additions & 0 deletions packages/data-schemas/src/app/web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe('loadWebSearchConfig', () => {
jinaApiKey: '${JINA_API_KEY}',
jinaApiUrl: '${JINA_API_URL}',
cohereApiKey: '${COHERE_API_KEY}',
cohereApiUrl: '${COHERE_API_URL}',
safeSearch: SafeSearchTypes.MODERATE,
rerankerType: undefined,
tavilyApiKey: '${TAVILY_API_KEY}',
Expand Down Expand Up @@ -215,20 +216,23 @@ describe('loadWebSearchConfig', () => {
expect(result?.searxngInstanceUrl).toBe('${SEARXNG_INSTANCE_URL}');
expect(result?.firecrawlApiUrl).toBe('${FIRECRAWL_API_URL}');
expect(result?.jinaApiUrl).toBe('${JINA_API_URL}');
expect(result?.cohereApiUrl).toBe('${COHERE_API_URL}');
});

it('should preserve custom URLs', () => {
const config: TCustomConfig['webSearch'] = {
searxngInstanceUrl: 'https://custom-searxng.com',
firecrawlApiUrl: 'https://custom-firecrawl.com',
jinaApiUrl: 'https://custom-jina.com',
cohereApiUrl: 'https://litellm.internal/v2/rerank',
};

const result = loadWebSearchConfig(config);

expect(result?.searxngInstanceUrl).toBe('https://custom-searxng.com');
expect(result?.firecrawlApiUrl).toBe('https://custom-firecrawl.com');
expect(result?.jinaApiUrl).toBe('https://custom-jina.com');
expect(result?.cohereApiUrl).toBe('https://litellm.internal/v2/rerank');
});
});
});
Expand Down
8 changes: 7 additions & 1 deletion packages/data-schemas/src/app/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export const webSearchAuth = {
/** Optional (0) */
jinaApiUrl: 0 as const,
},
cohere: { cohereApiKey: 1 as const },
cohere: {
cohereApiKey: 1 as const,
/** Optional (0) */
cohereApiUrl: 0 as const,
Comment thread
busla marked this conversation as resolved.
},
},
};

Expand Down Expand Up @@ -102,6 +106,7 @@ export function loadWebSearchConfig(
const jinaApiKey = config?.jinaApiKey ?? '${JINA_API_KEY}';
const jinaApiUrl = config?.jinaApiUrl ?? '${JINA_API_URL}';
const cohereApiKey = config?.cohereApiKey ?? '${COHERE_API_KEY}';
const cohereApiUrl = config?.cohereApiUrl ?? '${COHERE_API_URL}';
const safeSearch = config?.safeSearch ?? SafeSearchTypes.MODERATE;
const rerankerType = config?.rerankerType;
const searxngSearchOptions = config?.searxngSearchOptions && {
Expand All @@ -116,6 +121,7 @@ export function loadWebSearchConfig(
jinaApiKey,
jinaApiUrl,
cohereApiKey,
cohereApiUrl,
serperApiKey,
searxngApiKey,
tavilyApiKey,
Expand Down
3 changes: 2 additions & 1 deletion packages/data-schemas/src/types/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export type TWebSearchKeys =
| 'keenableApiUrl'
| 'jinaApiKey'
| 'jinaApiUrl'
| 'cohereApiKey';
| 'cohereApiKey'
| 'cohereApiUrl';

export type TWebSearchCategories =
| SearchCategories.PROVIDERS
Expand Down