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