From 099fd84bdc4e7fd652c00d1480b119a9124fce4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Wed, 2 Sep 2026 16:04:23 +0000 Subject: [PATCH 1/2] feat: support custom Cohere reranker API URL for web search Adds cohereApiUrl (env COHERE_API_URL) alongside the existing jinaApiUrl, so a Cohere-compatible reranking endpoint (e.g. a LiteLLM proxy) can be used instead of api.cohere.com. Mirrors the Jina URL override pattern across the webSearch schema, auth loading, and SSRF preflight. Requires @librechat/agents to accept a cohereApiUrl in createReranker (companion PR: danny-avila/agents). --- librechat.example.yaml | 1 + packages/api/src/web/web.spec.ts | 66 +++++++++++++++++++++++ packages/api/src/web/web.ts | 1 + packages/data-provider/src/config.ts | 1 + packages/data-schemas/src/app/web.spec.ts | 4 ++ packages/data-schemas/src/app/web.ts | 8 ++- packages/data-schemas/src/types/web.ts | 3 +- 7 files changed, 82 insertions(+), 2 deletions(-) diff --git a/librechat.example.yaml b/librechat.example.yaml index 81322cae58d..b31e35c25c9 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -1219,6 +1219,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}' diff --git a/packages/api/src/web/web.spec.ts b/packages/api/src/web/web.spec.ts index 1a8ae5ceda6..a91f9d05c16 100644 --- a/packages/api/src/web/web.spec.ts +++ b/packages/api/src/web/web.spec.ts @@ -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 = {}; + 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'), @@ -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 = {}; + 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'); diff --git a/packages/api/src/web/web.ts b/packages/api/src/web/web.ts index ca3372a1c59..8af438278fb 100644 --- a/packages/api/src/web/web.ts +++ b/packages/api/src/web/web.ts @@ -19,6 +19,7 @@ const USER_PROVIDED_URL_KEYS = new Map([ ['searxngInstanceUrl', 'searxngApiKey'], ['firecrawlApiUrl', 'firecrawlApiKey'], ['jinaApiUrl', 'jinaApiKey'], + ['cohereApiUrl', 'cohereApiKey'], ]); /** diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index a8e86e9dfe5..dffa5b3f45f 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -2710,6 +2710,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}'), searchProvider: z.nativeEnum(SearchProviders).optional(), scraperProvider: z.nativeEnum(ScraperProviders).optional(), rerankerType: z.nativeEnum(RerankerTypes).optional(), diff --git a/packages/data-schemas/src/app/web.spec.ts b/packages/data-schemas/src/app/web.spec.ts index fd073ca0ef8..06646efbcc7 100644 --- a/packages/data-schemas/src/app/web.spec.ts +++ b/packages/data-schemas/src/app/web.spec.ts @@ -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}', @@ -215,6 +216,7 @@ 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', () => { @@ -222,6 +224,7 @@ describe('loadWebSearchConfig', () => { 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); @@ -229,6 +232,7 @@ describe('loadWebSearchConfig', () => { 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'); }); }); }); diff --git a/packages/data-schemas/src/app/web.ts b/packages/data-schemas/src/app/web.ts index c30ace3ac44..0fe381114bb 100644 --- a/packages/data-schemas/src/app/web.ts +++ b/packages/data-schemas/src/app/web.ts @@ -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, + }, }, }; @@ -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 && { @@ -116,6 +121,7 @@ export function loadWebSearchConfig( jinaApiKey, jinaApiUrl, cohereApiKey, + cohereApiUrl, serperApiKey, searxngApiKey, tavilyApiKey, diff --git a/packages/data-schemas/src/types/web.ts b/packages/data-schemas/src/types/web.ts index 147025cb845..6829c39345d 100644 --- a/packages/data-schemas/src/types/web.ts +++ b/packages/data-schemas/src/types/web.ts @@ -14,7 +14,8 @@ export type TWebSearchKeys = | 'keenableApiUrl' | 'jinaApiKey' | 'jinaApiUrl' - | 'cohereApiKey'; + | 'cohereApiKey' + | 'cohereApiUrl'; export type TWebSearchCategories = | SearchCategories.PROVIDERS From 95e1c1845f97503573628f654d8ad9f5e0067706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Mon, 7 Sep 2026 15:41:14 +0000 Subject: [PATCH 2/2] feat: expose Cohere reranker API URL in client dialog and proxy exemptions - Expose cohereApiUrl input and help link in web search credential dialog - Include cohereApiUrl in WEB_SEARCH_URL_KEYS for outbound SSRF proxy exemption - Update client auth types, form handling, translations, and unit tests - Update AppService and agent unit tests --- .../SidePanel/Agents/Search/ApiKeyDialog.test.tsx | 8 ++++++++ .../SidePanel/Agents/Search/ApiKeyDialog.tsx | 8 ++++++++ .../Plugins/__tests__/useAuthSearchTool.test.ts | 14 ++++++++++++++ client/src/hooks/Plugins/useAuthSearchTool.ts | 2 ++ client/src/locales/en/translation.json | 2 ++ packages/api/src/app/AppService.spec.ts | 1 + packages/api/src/web/agent.spec.ts | 10 ++++++++++ packages/api/src/web/agent.ts | 1 + 8 files changed, 46 insertions(+) diff --git a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.test.tsx b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.test.tsx index fca0ca3fc1d..60c6bdddbb5 100644 --- a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.test.tsx +++ b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.test.tsx @@ -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', () => { @@ -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', () => { @@ -126,6 +128,7 @@ describe('ApiKeyDialog', () => { render(); 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', () => { @@ -134,7 +137,9 @@ describe('ApiKeyDialog', () => { }); render(); 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', () => { @@ -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', () => { diff --git a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx index 90eb7292239..5689dff8057 100644 --- a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx +++ b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx @@ -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'), + }, + }, }, }, ]; diff --git a/client/src/hooks/Plugins/__tests__/useAuthSearchTool.test.ts b/client/src/hooks/Plugins/__tests__/useAuthSearchTool.test.ts index 5fdfeaf4adb..cebea7408fd 100644 --- a/client/src/hooks/Plugins/__tests__/useAuthSearchTool.test.ts +++ b/client/src/hooks/Plugins/__tests__/useAuthSearchTool.test.ts @@ -29,6 +29,7 @@ const emptyForm = (): SearchApiKeyFormData => ({ jinaApiKey: '', jinaApiUrl: '', cohereApiKey: '', + cohereApiUrl: '', }); describe('useAuthSearchTool', () => { @@ -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()); diff --git a/client/src/hooks/Plugins/useAuthSearchTool.ts b/client/src/hooks/Plugins/useAuthSearchTool.ts index 1cb65ede0fa..cd9c3d4a53a 100644 --- a/client/src/hooks/Plugins/useAuthSearchTool.ts +++ b/client/src/hooks/Plugins/useAuthSearchTool.ts @@ -27,6 +27,7 @@ export type SearchApiKeyFormData = { jinaApiKey: string; jinaApiUrl: string; cohereApiKey: string; + cohereApiUrl: string; }; export type SearchApiKeyDirtyFields = Partial>; @@ -78,6 +79,7 @@ const useAuthSearchTool = (options?: { isEntityTool: boolean }) => { jinaApiKey: data.jinaApiKey, jinaApiUrl: data.jinaApiUrl, cohereApiKey: data.cohereApiKey, + cohereApiUrl: data.cohereApiUrl, }).reduce( (acc, [key, value]) => { const wasExplicitlyCleared = diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index a74bc7141fe..4b47f1f216d 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -2956,6 +2956,7 @@ "com_ui_wait_for_tool_steps": "Wait for the next step instead", "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)", @@ -2973,6 +2974,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", diff --git a/packages/api/src/app/AppService.spec.ts b/packages/api/src/app/AppService.spec.ts index f4f64ef23c9..664e5970720 100644 --- a/packages/api/src/app/AppService.spec.ts +++ b/packages/api/src/app/AppService.spec.ts @@ -129,6 +129,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}', diff --git a/packages/api/src/web/agent.spec.ts b/packages/api/src/web/agent.spec.ts index 20b35c95535..ff0d8e53611 100644 --- a/packages/api/src/web/agent.spec.ts +++ b/packages/api/src/web/agent.spec.ts @@ -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'; diff --git a/packages/api/src/web/agent.ts b/packages/api/src/web/agent.ts index 5e73700d9f4..3d0117d943c 100644 --- a/packages/api/src/web/agent.ts +++ b/packages/api/src/web/agent.ts @@ -15,6 +15,7 @@ const WEB_SEARCH_URL_KEYS = [ 'searxngInstanceUrl', 'firecrawlApiUrl', 'jinaApiUrl', + 'cohereApiUrl', 'tavilySearchUrl', 'tavilyExtractUrl', 'keenableApiUrl',