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 7daf6ba84cf..7f53e2e9fff 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 ffcb7f12cfa..8f2dfd76fa8 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>; @@ -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 = diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 6ac0955e7b1..cc7d1805e3f 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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)", @@ -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", diff --git a/librechat.example.yaml b/librechat.example.yaml index 402e682da83..48a2f035b86 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -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}' diff --git a/packages/api/src/app/AppService.spec.ts b/packages/api/src/app/AppService.spec.ts index 0830a62e180..003b45869cd 100644 --- a/packages/api/src/app/AppService.spec.ts +++ b/packages/api/src/app/AppService.spec.ts @@ -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}', 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', 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 c294caccfcc..3c29f1927ea 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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}'), 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