diff --git a/.env.example b/.env.example index 9a5514ee9f7..b5eef11e677 100644 --- a/.env.example +++ b/.env.example @@ -833,8 +833,12 @@ OPENWEATHER_API_KEY= # Reranker (Required) # JINA_API_KEY=your_jina_api_key +# Optional: Custom Jina API URL (e.g., self-hosted or alternative provider) +# JINA_API_URL=your_jina_api_url # or # COHERE_API_KEY=your_cohere_api_key +# Optional: Custom Cohere API URL (e.g., Azure AI serverless deployment) +# COHERE_API_URL=your_cohere_api_url #======================# # MCP Configuration # diff --git a/COHERE_RERANKER_AGENTS_PLAN.md b/COHERE_RERANKER_AGENTS_PLAN.md new file mode 100644 index 00000000000..39bd485bedb --- /dev/null +++ b/COHERE_RERANKER_AGENTS_PLAN.md @@ -0,0 +1,406 @@ +# Implementation Plan: Configurable Cohere Reranker URL in `@librechat/agents` + +## Context + +LibreChat issue [#12328](https://github.com/danny-avila/LibreChat/issues/12328) requests support for a configurable Cohere reranker endpoint so users can target Azure AI Foundry's serverless Cohere deployments (or any other Cohere-compatible host) instead of the hardcoded `https://api.cohere.com/v2/rerank`. + +The LibreChat-side changes are already merged on branch `claude/review-librechat-issue-9XgW7` (PR target: `danny-avila/LibreChat`). The schema, config loader, SSRF allowlist, UI dialog, locales, env example, and tests all surface a new optional `cohereApiUrl` field. + +**The remaining piece is in `@librechat/agents`** — the `CohereReranker` class still hardcodes the URL and `createReranker` does not accept `cohereApiUrl`. This plan covers exactly that gap. Once shipped, LibreChat's already-merged `cohereApiUrl` flow takes effect end-to-end without any further LibreChat changes. + +## Repository + +- Repo: `https://github.com/danny-avila/agents` +- Package name: `@librechat/agents` +- Current version (verified at time of writing): `3.1.78` +- Language: TypeScript + +## Branch & Commit Convention + +- Branch: `feat/cohere-reranker-api-url` (slash-based, descriptive) +- Commit format: `feat: ` — semantic, lowercase +- Bump to **`3.1.79`** (patch is fine: additive, fully backwards compatible) + +## Target Files (verified to exist on `main`) + +| File | Role | +|---|---| +| `src/tools/search/rerankers.ts` | `CohereReranker` class + `createReranker` factory | +| `src/tools/search/types.ts` | `SearchToolConfig` and reranker option types | +| `src/tools/search/tool.ts` | `createSearchTool` destructures auth fields and forwards to `createReranker` | +| `src/tools/search/jina-reranker.test.ts` | Existing test that mirrors the desired pattern | +| `src/tools/search/cohere-reranker.test.ts` | **NEW** — test file to be added | +| `package.json` | Version bump | + +## Detailed Changes + +### 1. `src/tools/search/rerankers.ts` + +**Current `CohereReranker` constructor (verbatim):** + +```ts +export class CohereReranker extends BaseReranker { + constructor({ + apiKey = process.env.COHERE_API_KEY, + logger, + }: { + apiKey?: string; + logger?: t.Logger; + }) { + super(logger); + this.apiKey = apiKey; + } +``` + +**Replace with** (mirroring `JinaReranker` exactly): + +```ts +export class CohereReranker extends BaseReranker { + private apiUrl: string; + + constructor({ + apiKey = process.env.COHERE_API_KEY, + apiUrl = process.env.COHERE_API_URL || 'https://api.cohere.com/v2/rerank', + logger, + }: { + apiKey?: string; + apiUrl?: string; + logger?: t.Logger; + }) { + super(logger); + this.apiKey = apiKey; + this.apiUrl = apiUrl; + } +``` + +**Current `rerank()` body — hardcoded URL** (verbatim, find this exact string): + +```ts + const response = await axios.post( + 'https://api.cohere.com/v2/rerank', + requestData, +``` + +**Replace with:** + +```ts + const response = await axios.post( + this.apiUrl, + requestData, +``` + +**Also update the debug log** at the top of `rerank()`: + +Current: +```ts +this.logger.debug(`Reranking ${documents.length} chunks with Cohere`); +``` + +Replace with: +```ts +this.logger.debug(`Reranking ${documents.length} chunks with Cohere using API URL: ${this.apiUrl}`); +``` + +**Update `createReranker` factory** — current (verbatim): + +```ts +export const createReranker = (config: { + rerankerType: t.RerankerType; + jinaApiKey?: string; + jinaApiUrl?: string; + cohereApiKey?: string; + logger?: t.Logger; +}): BaseReranker | undefined => { + const { rerankerType, jinaApiKey, jinaApiUrl, cohereApiKey, logger } = config; + + // Create a default logger if none is provided + const defaultLogger = logger || createDefaultLogger(); + + switch (rerankerType.toLowerCase()) { + case 'jina': + return new JinaReranker({ apiKey: jinaApiKey, apiUrl: jinaApiUrl, logger: defaultLogger }); + case 'cohere': + return new CohereReranker({ + apiKey: cohereApiKey, + logger: defaultLogger, + }); +``` + +**Replace with:** + +```ts +export const createReranker = (config: { + rerankerType: t.RerankerType; + jinaApiKey?: string; + jinaApiUrl?: string; + cohereApiKey?: string; + cohereApiUrl?: string; + logger?: t.Logger; +}): BaseReranker | undefined => { + const { rerankerType, jinaApiKey, jinaApiUrl, cohereApiKey, cohereApiUrl, logger } = config; + + // Create a default logger if none is provided + const defaultLogger = logger || createDefaultLogger(); + + switch (rerankerType.toLowerCase()) { + case 'jina': + return new JinaReranker({ apiKey: jinaApiKey, apiUrl: jinaApiUrl, logger: defaultLogger }); + case 'cohere': + return new CohereReranker({ + apiKey: cohereApiKey, + apiUrl: cohereApiUrl, + logger: defaultLogger, + }); +``` + +> Do NOT touch the `'infinity'`, `'none'`, or `default` switch arms. + +### 2. `src/tools/search/types.ts` + +Locate the `SearchToolConfig`-style interface around line 219 (the block that contains `jinaApiKey`, `jinaApiUrl`, `cohereApiKey`). + +**Current (verbatim, lines ~219–221):** + +```ts + jinaApiKey?: string; + jinaApiUrl?: string; + cohereApiKey?: string; +``` + +**Replace with:** + +```ts + jinaApiKey?: string; + jinaApiUrl?: string; + cohereApiKey?: string; + cohereApiUrl?: string; +``` + +> The interface name lives a few lines above the match; keep the indentation identical to neighbouring fields. + +### 3. `src/tools/search/tool.ts` + +Around line 354–356 the destructure pulls `jinaApiKey`, `jinaApiUrl`, `cohereApiKey` from `config`. + +**Find:** +```ts + jinaApiKey, + jinaApiUrl, + cohereApiKey, + onSearchResults: _onSearchResults, +``` + +**Replace with:** +```ts + jinaApiKey, + jinaApiUrl, + cohereApiKey, + cohereApiUrl, + onSearchResults: _onSearchResults, +``` + +Around line 431–435 the destructured fields are passed into `createReranker`. + +**Find:** +```ts + const selectedReranker = createReranker({ + rerankerType, + jinaApiKey, + jinaApiUrl, + cohereApiKey, +``` + +**Replace with:** +```ts + const selectedReranker = createReranker({ + rerankerType, + jinaApiKey, + jinaApiUrl, + cohereApiKey, + cohereApiUrl, +``` + +### 4. `src/tools/search/cohere-reranker.test.ts` (NEW) + +Mirror `jina-reranker.test.ts` exactly. The constructor-level tests are the important ones — the network path doesn't need to be exercised. + +```ts +import { CohereReranker } from './rerankers'; +import { createDefaultLogger } from './utils'; + +describe('CohereReranker', () => { + const mockLogger = createDefaultLogger(); + + describe('constructor', () => { + it('should use default API URL when no apiUrl is provided', () => { + const originalEnv = process.env.COHERE_API_URL; + delete process.env.COHERE_API_URL; + + const reranker = new CohereReranker({ + apiKey: 'test-key', + logger: mockLogger, + }); + + // Access private property for testing + const apiUrl = (reranker as any).apiUrl; + expect(apiUrl).toBe('https://api.cohere.com/v2/rerank'); + + if (originalEnv) { + process.env.COHERE_API_URL = originalEnv; + } + }); + + it('should use custom API URL when provided', () => { + const customUrl = 'https://my-azure.endpoint.com/v1/rerank'; + const reranker = new CohereReranker({ + apiKey: 'test-key', + apiUrl: customUrl, + logger: mockLogger, + }); + + const apiUrl = (reranker as any).apiUrl; + expect(apiUrl).toBe(customUrl); + }); + + it('should use environment variable COHERE_API_URL when available', () => { + const originalEnv = process.env.COHERE_API_URL; + process.env.COHERE_API_URL = 'https://env-cohere.example.com/v2/rerank'; + + const reranker = new CohereReranker({ + apiKey: 'test-key', + logger: mockLogger, + }); + + const apiUrl = (reranker as any).apiUrl; + expect(apiUrl).toBe('https://env-cohere.example.com/v2/rerank'); + + if (originalEnv) { + process.env.COHERE_API_URL = originalEnv; + } else { + delete process.env.COHERE_API_URL; + } + }); + + it('should prioritize explicit apiUrl over environment variable', () => { + const originalEnv = process.env.COHERE_API_URL; + process.env.COHERE_API_URL = 'https://env-cohere.example.com/v2/rerank'; + + const customUrl = 'https://explicit-cohere.example.com/v2/rerank'; + const reranker = new CohereReranker({ + apiKey: 'test-key', + apiUrl: customUrl, + logger: mockLogger, + }); + + const apiUrl = (reranker as any).apiUrl; + expect(apiUrl).toBe(customUrl); + + if (originalEnv) { + process.env.COHERE_API_URL = originalEnv; + } else { + delete process.env.COHERE_API_URL; + } + }); + }); +}); +``` + +### 5. `package.json` + +Bump: + +```diff +- "version": "3.1.78", ++ "version": "3.1.79", +``` + +## Verification + +Run from repo root: + +```bash +npm install +npm run lint # must pass clean +npm run build # tsc must succeed +npm test -- src/tools/search/cohere-reranker.test.ts +npm test -- src/tools/search/jina-reranker.test.ts # regression check +npm test # full suite +``` + +All must pass. The lint config enforces import ordering and type-safety rules — no `any` outside the `(reranker as any).apiUrl` test helper that mirrors the existing Jina test pattern. + +## Smoke Test (manual, optional) + +Quick sanity check from a Node REPL once built: + +```ts +import { CohereReranker } from '@librechat/agents'; + +const r1 = new CohereReranker({ apiKey: 'x' }); +console.log((r1 as any).apiUrl); +// → 'https://api.cohere.com/v2/rerank' + +const r2 = new CohereReranker({ apiKey: 'x', apiUrl: 'https://azure-host/rerank' }); +console.log((r2 as any).apiUrl); +// → 'https://azure-host/rerank' +``` + +## Commit & PR + +Single commit (squash on merge if maintainer prefers): + +``` +feat: configurable Cohere reranker API URL + +Mirror the JinaReranker pattern by accepting an optional `apiUrl` +in the CohereReranker constructor (with COHERE_API_URL env-var +fallback) and propagating `cohereApiUrl` through `createReranker` +and the `createSearchTool` config. + +Defaults to https://api.cohere.com/v2/rerank, fully backwards +compatible. Enables Azure AI Foundry serverless Cohere endpoints +and other Cohere-compatible deployments. + +Refs https://github.com/danny-avila/LibreChat/issues/12328 +``` + +PR title: `feat: configurable Cohere reranker API URL` + +PR body must include: +- Link to LibreChat issue #12328. +- Note that the LibreChat-side wiring (schema, UI, env, SSRF allowlist) is already on branch `claude/review-librechat-issue-9XgW7` and will pick up `cohereApiUrl` automatically once this version ships. +- Backwards compatibility statement: existing callers that omit `apiUrl`/`cohereApiUrl` keep the current behaviour bit-for-bit. + +## Out of Scope (do NOT include here) + +- **Custom auth headers (e.g. `api-key:` instead of `Authorization: Bearer`).** Azure AI Foundry's Cohere serverless endpoints accept `Authorization: Bearer `, so the URL change alone solves the primary use case. If a future Azure ML route needs `api-key:`, it can be a follow-up PR adding optional `authHeaderName` / `authHeaderPrefix` options. +- **Changes to `InfinityReranker` or `JinaReranker`** — leave untouched. +- **Cohere model selection.** `'rerank-v3.5'` stays the default; if Azure exposes a different model name, that's a separate option. +- **Any LibreChat repo changes** — already done on `claude/review-librechat-issue-9XgW7`. + +## After Merge & npm Publish + +Once `@librechat/agents@3.1.79` is published to npm, LibreChat needs a one-line follow-up bump: + +In `LibreChat/api/package.json`: +```diff +- "@librechat/agents": "^3.1.78", ++ "@librechat/agents": "^3.1.79", +``` + +Then `npm install` to refresh the lockfile and commit. That commit can ride on the existing `claude/review-librechat-issue-9XgW7` branch or be a separate small PR — whichever the LibreChat maintainers prefer. + +## Acceptance Criteria + +- [ ] `CohereReranker` accepts optional `apiUrl`; defaults preserved. +- [ ] `process.env.COHERE_API_URL` is honoured when constructor `apiUrl` is omitted. +- [ ] Explicit constructor `apiUrl` wins over the env var. +- [ ] `createReranker` accepts and forwards `cohereApiUrl`. +- [ ] `createSearchTool` config destructures `cohereApiUrl` and passes it to `createReranker`. +- [ ] `SearchToolConfig` (or equivalent in `types.ts`) declares `cohereApiUrl?: string`. +- [ ] New test file covers the four constructor cases (default, explicit, env, explicit-overrides-env). +- [ ] Existing Jina, Cohere (network mocked, if any), and Infinity tests still pass. +- [ ] `npm run lint` clean. +- [ ] `npm run build` clean. +- [ ] Version bumped to `3.1.79` in `package.json`. +- [ ] PR opened against `main` referencing LibreChat issue #12328. diff --git a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx index 0202ec96ee8..c9cd5e62e1c 100644 --- a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx +++ b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx @@ -129,6 +129,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/useAuthSearchTool.ts b/client/src/hooks/Plugins/useAuthSearchTool.ts index ffb156dba10..3f0ed5192d3 100644 --- a/client/src/hooks/Plugins/useAuthSearchTool.ts +++ b/client/src/hooks/Plugins/useAuthSearchTool.ts @@ -18,6 +18,7 @@ export type SearchApiKeyFormData = { jinaApiKey: string; jinaApiUrl: string; cohereApiKey: string; + cohereApiUrl: string; }; const useAuthSearchTool = (options?: { isEntityTool: boolean }) => { @@ -59,6 +60,7 @@ const useAuthSearchTool = (options?: { isEntityTool: boolean }) => { jinaApiKey: data.jinaApiKey, jinaApiUrl: data.jinaApiUrl, cohereApiKey: data.cohereApiKey, + cohereApiUrl: data.cohereApiUrl, }).reduce( (acc, [key, value]) => { if (value) { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 81f513560c3..ee97a40c6bc 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1673,6 +1673,7 @@ "com_ui_view_memory": "View Memory", "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)", @@ -1687,6 +1688,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_jina_key": "Get your Jina API key", "com_ui_web_search_reranker_jina_url_help": "Learn about Jina Rerank API", diff --git a/packages/api/src/app/AppService.spec.ts b/packages/api/src/app/AppService.spec.ts index 2c07460b849..b2c0fd2dc7f 100644 --- a/packages/api/src/app/AppService.spec.ts +++ b/packages/api/src/app/AppService.spec.ts @@ -121,6 +121,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/web.spec.ts b/packages/api/src/web/web.spec.ts index a33a6e1eb11..a3050b9b4f8 100644 --- a/packages/api/src/web/web.spec.ts +++ b/packages/api/src/web/web.spec.ts @@ -772,8 +772,10 @@ describe('web.ts', () => { // Check rerankers expect(webSearchAuth.rerankers).toHaveProperty('jina'); expect(webSearchAuth.rerankers.jina).toHaveProperty('jinaApiKey', 1); + expect(webSearchAuth.rerankers.jina).toHaveProperty('jinaApiUrl', 0); expect(webSearchAuth.rerankers).toHaveProperty('cohere'); expect(webSearchAuth.rerankers.cohere).toHaveProperty('cohereApiKey', 1); + expect(webSearchAuth.rerankers.cohere).toHaveProperty('cohereApiUrl', 0); }); it('should mark required keys with value 1', () => { @@ -787,6 +789,8 @@ describe('web.ts', () => { it('should mark optional keys with value 0', () => { // Keys with value 0 are optional expect(webSearchAuth.scrapers.firecrawl.firecrawlApiUrl).toBe(0); + expect(webSearchAuth.rerankers.jina.jinaApiUrl).toBe(0); + expect(webSearchAuth.rerankers.cohere.cohereApiUrl).toBe(0); }); }); describe('loadWebSearchAuth with specific services', () => { diff --git a/packages/api/src/web/web.ts b/packages/api/src/web/web.ts index 06d65d4d985..99d918de820 100644 --- a/packages/api/src/web/web.ts +++ b/packages/api/src/web/web.ts @@ -22,6 +22,7 @@ const USER_PROVIDED_URL_KEYS = new Set([ 'searxngInstanceUrl', 'firecrawlApiUrl', 'jinaApiUrl', + 'cohereApiUrl', ]); /** diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index d8fc071c6f3..c6390c74cb2 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1148,6 +1148,7 @@ export const webSearchSchema = z.object({ jinaApiKey: z.string().optional().default('${JINA_API_KEY}'), jinaApiUrl: z.string().optional().default('${JINA_API_URL}'), cohereApiKey: z.string().optional().default('${COHERE_API_KEY}'), + 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 9a9a0596dcf..0d02ee2e89a 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}', @@ -158,6 +159,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', () => { @@ -165,6 +167,7 @@ describe('loadWebSearchConfig', () => { searxngInstanceUrl: 'https://custom-searxng.com', firecrawlApiUrl: 'https://custom-firecrawl.com', jinaApiUrl: 'https://custom-jina.com', + cohereApiUrl: 'https://custom-cohere.com', }; const result = loadWebSearchConfig(config); @@ -172,6 +175,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://custom-cohere.com'); }); }); }); diff --git a/packages/data-schemas/src/app/web.ts b/packages/data-schemas/src/app/web.ts index 989931db87e..cde9dd602d5 100644 --- a/packages/data-schemas/src/app/web.ts +++ b/packages/data-schemas/src/app/web.ts @@ -38,7 +38,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, + }, }, }; @@ -83,6 +87,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; @@ -92,6 +97,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 69018234a30..7c92d78ceb9 100644 --- a/packages/data-schemas/src/types/web.ts +++ b/packages/data-schemas/src/types/web.ts @@ -12,7 +12,8 @@ export type TWebSearchKeys = | 'tavilyExtractUrl' | 'jinaApiKey' | 'jinaApiUrl' - | 'cohereApiKey'; + | 'cohereApiKey' + | 'cohereApiUrl'; export type TWebSearchCategories = | SearchCategories.PROVIDERS