From 7c63e9914947ab9ee3c7485d7e7cbd7c8cc509da Mon Sep 17 00:00:00 2001 From: "Andrew B." <37745667+AndrianBalanescu@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:08:31 -0500 Subject: [PATCH] fix(rerank): add voyage format adapter for request/response translation (#7809) (#7813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(rerank): add voyage format adapter for request/response translation (#7809) Voyage AI is not Cohere-compatible: - Uses top_k instead of top_n (top_n is rejected with 400) - Rejects empty-string documents (Cohere tolerates them) - Returns {data:[{relevance_score,index}]} not {results:[…]} Add format: 'voyage' to the registry entry and implement both transformRequestForProvider and transformResponseFromProvider adapters: Request: map top_n→top_k, filter empty/whitespace-only documents Response: map data[]→results[], remap filtered indices back to caller's original document positions, sort by score desc, honor top_n Follows the existing nvidia/deepinfra adapter pattern. 13 new tests, all existing rerank tests still pass. * fix(rerank): preserve whitespace-only documents in voyage adapter Voyage API accepts whitespace-only documents (probed live). Changed filter from text.trim() to text !== '' so only exact empty strings are dropped. Updated both request adapter and response index-map reconstruction, plus tests pinning the behavior. * fix(rerank): force return_documents:false upstream + isolate voyage-7809 test DB Voyage echoes documents as plain strings (not Cohere {text}); we never rely on that echo (document text is always synthesized locally from the caller's originals), so force return_documents:false on the upstream request to make that explicit and never trust an echoed document. Folds in the corresponding delta from the now-closed #7811. Also isolates tests/unit/rerank-voyage-7809.test.ts's SQLite usage behind a temp DATA_DIR + core.resetDbInstance() in test.after, since importing open-sse/handlers/rerank.ts pulls in @/lib/usageDb (migrations run on import) — the test must never touch the shared/real DB. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza --- open-sse/config/rerankRegistry.ts | 4 + open-sse/handlers/rerank.ts | 54 +++++++ tests/unit/rerank-voyage-7809.test.ts | 225 ++++++++++++++++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 tests/unit/rerank-voyage-7809.test.ts diff --git a/open-sse/config/rerankRegistry.ts b/open-sse/config/rerankRegistry.ts index 7b8a79801c..a2241b8a49 100644 --- a/open-sse/config/rerankRegistry.ts +++ b/open-sse/config/rerankRegistry.ts @@ -50,11 +50,15 @@ export const RERANK_PROVIDERS = { ], }, + // Voyage AI is NOT Cohere-compatible: uses `top_k` (not `top_n`), rejects empty-string + // documents, and returns `{data:[{relevance_score,index}]}` instead of `{results:[…]}`. + // The `voyage` format adapter in open-sse/handlers/rerank.ts handles both directions (#7809). "voyage-ai": { id: "voyage-ai", baseUrl: "https://api.voyageai.com/v1/rerank", authType: "apikey", authHeader: "bearer", + format: "voyage", models: [ { id: "rerank-2.5", name: "Rerank 2.5" }, { id: "rerank-2.5-lite", name: "Rerank 2.5 Lite" }, diff --git a/open-sse/handlers/rerank.ts b/open-sse/handlers/rerank.ts index 6200a168ac..fb37f08267 100644 --- a/open-sse/handlers/rerank.ts +++ b/open-sse/handlers/rerank.ts @@ -47,6 +47,25 @@ function buildAuthHeader(providerConfig, token) { ), }; } + // Voyage AI: uses `top_k` instead of `top_n`, and rejects only exact empty + // strings (whitespace-only documents are accepted and ranked upstream). We + // filter out exact empty strings and track original indices implicitly via the + // response adapter, which reconstructs the map from options.documents (#7809). + // `return_documents` is always forced off upstream: Voyage echoes documents as + // plain strings (not Cohere's {text}), so we never rely on the echo — document + // text is always synthesized locally from the caller's originals (#7811). + if (providerConfig.format === "voyage") { + const docTexts = (body.documents || []) + .map((doc) => (typeof doc === "string" ? doc : doc?.text || "")) + .filter((text) => text !== ""); + return { + model: body.model, + query: body.query, + documents: docTexts, + top_k: body.top_n || docTexts.length, + return_documents: false, + }; + } // Default: Cohere-compatible format (used by Together, Fireworks, Cohere, SiliconFlow) return body; } @@ -94,6 +113,41 @@ function buildAuthHeader(providerConfig, token) { }, }; } + // Voyage AI returns {data:[{relevance_score,index}], usage:{total_tokens}} — `index` refers + // to the filtered document array we sent (empty strings removed). We remap back to the + // caller's original positions and sort by score descending, honoring top_n (#7809). + if (providerConfig.format === "voyage") { + const documents = Array.isArray(options.documents) ? options.documents : []; + const returnDocuments = options.return_documents !== false; + // Reconstruct the index map: the request adapter filtered out exact empty + // strings, so we replicate that filter here to get original → filtered mapping. + const indexMap = []; + documents.forEach((doc, i) => { + const text = typeof doc === "string" ? doc : doc?.text || ""; + if (text !== "") indexMap.push(i); + }); + const scored = (Array.isArray(data.data) ? data.data : []).map((entry) => { + const filteredIdx = entry.index ?? 0; + const originalIdx = indexMap[filteredIdx] ?? filteredIdx; + const doc = documents[originalIdx]; + const text = typeof doc === "string" ? doc : doc?.text || ""; + return { + index: originalIdx, + relevance_score: typeof entry.relevance_score === "number" ? entry.relevance_score : 0, + ...(returnDocuments ? { document: { text } } : {}), + }; + }); + scored.sort((a, b) => b.relevance_score - a.relevance_score); + const topN = typeof options.top_n === "number" && options.top_n > 0 ? options.top_n : undefined; + return { + id: `rerank-${Date.now()}`, + results: topN ? scored.slice(0, topN) : scored, + meta: { + api_version: { version: "2" }, + billed_units: { search_units: 1 }, + }, + }; + } return data; } diff --git a/tests/unit/rerank-voyage-7809.test.ts b/tests/unit/rerank-voyage-7809.test.ts new file mode 100644 index 0000000000..5605f19651 --- /dev/null +++ b/tests/unit/rerank-voyage-7809.test.ts @@ -0,0 +1,225 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Isolated DATA_DIR before any module that may open the SQLite singleton +// (open-sse/handlers/rerank.ts pulls in @/lib/usageDb, which triggers +// migrations on import) — never touch the shared/real DB (#7809/#7811). +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-rerank-voyage-7809-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { parseRerankModel, getRerankProvider } = + await import("../../open-sse/config/rerankRegistry.ts"); +const { transformRequestForProvider, transformResponseFromProvider } = + await import("../../open-sse/handlers/rerank.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── Registry ────────────────────────────────────────────────────────────── + +test("#7809 voyage-ai registry entry has format: 'voyage'", () => { + const cfg = getRerankProvider("voyage-ai"); + assert.ok(cfg, "voyage-ai provider should exist"); + assert.equal(cfg.format, "voyage"); + assert.equal(cfg.baseUrl, "https://api.voyageai.com/v1/rerank"); +}); + +test("#7809 parseRerankModel resolves voyage-ai/rerank-2.5", () => { + assert.deepEqual(parseRerankModel("voyage-ai/rerank-2.5"), { + provider: "voyage-ai", + model: "rerank-2.5", + }); +}); + +test("#7809 parseRerankModel resolves voyage alias → voyage-ai", () => { + assert.deepEqual(parseRerankModel("voyage/rerank-2.5-lite"), { + provider: "voyage-ai", + model: "rerank-2.5-lite", + }); +}); + +// ─── Request adapter ─────────────────────────────────────────────────────── + +test("#7809 voyage request adapter maps top_n → top_k and drops only exact empty strings", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformRequestForProvider(cfg, { + model: "rerank-2.5-lite", + query: "teste", + documents: ["doc ok", "", " ", "doc three"], + top_n: 3, + return_documents: true, + }); + assert.equal(out.top_k, 3); + assert.equal(out.top_n, undefined); + // Whitespace-only " " is preserved; only exact "" is dropped + assert.deepEqual(out.documents, ["doc ok", " ", "doc three"]); + assert.equal(out.model, "rerank-2.5-lite"); + assert.equal(out.query, "teste"); +}); + +test("#7809 voyage request adapter preserves whitespace-only document (Voyage accepts them)", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformRequestForProvider(cfg, { + model: "rerank-2.5-lite", + query: "teste", + documents: ["doc ok", " "], + top_n: 2, + }); + // Whitespace-only " " must be sent upstream — Voyage ranks it + assert.equal(out.documents.length, 2); + assert.deepEqual(out.documents, ["doc ok", " "]); +}); + +test("#7809 voyage request adapter handles {text} object documents", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformRequestForProvider(cfg, { + model: "rerank-2.5", + query: "q", + documents: [{ text: "hello" }, { text: "" }, "world"], + top_n: 2, + }); + assert.deepEqual(out.documents, ["hello", "world"]); + assert.equal(out.top_k, 2); +}); + +test("#7809 voyage request adapter defaults top_k to filtered doc count when top_n omitted", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformRequestForProvider(cfg, { + model: "rerank-2.5", + query: "q", + documents: ["a", "b", "c"], + top_n: 0, + }); + // top_n is 0 (falsy) so fallback to docTexts.length + assert.equal(out.top_k, 3); +}); + +test("#7809 voyage request adapter does not include __voyageIndexMap in serialized body", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformRequestForProvider(cfg, { + model: "rerank-2.5", + query: "q", + documents: ["a", "b"], + top_n: 2, + }); + const serialized = JSON.parse(JSON.stringify(out)); + assert.equal(serialized.__voyageIndexMap, undefined); +}); + +// ─── Response adapter ────────────────────────────────────────────────────── + +test("#7809 voyage response adapter maps data[] → Cohere results[] sorted desc", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformResponseFromProvider( + cfg, + { + object: "list", + data: [ + { relevance_score: 0.3, index: 0 }, + { relevance_score: 0.9, index: 1 }, + { relevance_score: 0.6, index: 2 }, + ], + model: "rerank-2.5-lite", + usage: { total_tokens: 57 }, + }, + { documents: ["doc a", "doc b", "doc c"], top_n: 3, return_documents: true } + ); + assert.equal(out.results.length, 3); + assert.equal(out.results[0].index, 1); // 0.9 highest + assert.equal(out.results[0].relevance_score, 0.9); + assert.equal(out.results[0].document.text, "doc b"); + assert.equal(out.results[1].index, 2); // 0.6 next + assert.equal(out.results[2].index, 0); // 0.3 lowest +}); + +test("#7809 voyage response adapter honors top_n", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformResponseFromProvider( + cfg, + { + data: [ + { relevance_score: 0.1, index: 0 }, + { relevance_score: 0.8, index: 1 }, + ], + }, + { documents: ["a", "b"], top_n: 1, return_documents: true } + ); + assert.equal(out.results.length, 1); + assert.equal(out.results[0].index, 1); +}); + +test("#7809 voyage response adapter omits document text when return_documents=false", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformResponseFromProvider( + cfg, + { data: [{ relevance_score: 0.5, index: 0 }] }, + { documents: ["a"], return_documents: false } + ); + assert.equal(out.results[0].document, undefined); +}); + +test("#7809 voyage response adapter remaps indices when empty-string documents were filtered", () => { + const cfg = getRerankProvider("voyage-ai"); + // Original docs: ["doc0", "", "doc2"] → filtered to ["doc0", "doc2"] + // Voyage sees index 0 → original 0, index 1 → original 2 + const out = transformResponseFromProvider( + cfg, + { + data: [ + { relevance_score: 0.95, index: 1 }, // "doc2" in filtered array + { relevance_score: 0.4, index: 0 }, // "doc0" in filtered array + ], + }, + { documents: ["doc0", "", "doc2"], top_n: 2, return_documents: true } + ); + assert.equal(out.results[0].index, 2); // remapped to original position + assert.equal(out.results[0].document.text, "doc2"); + assert.equal(out.results[1].index, 0); + assert.equal(out.results[1].document.text, "doc0"); +}); + +test("#7809 voyage response adapter preserves whitespace-only in index map", () => { + const cfg = getRerankProvider("voyage-ai"); + // Whitespace-only doc must pass through: ["a", " "] → 2 docs sent + const out = transformResponseFromProvider( + cfg, + { + data: [ + { relevance_score: 0.8, index: 0 }, + { relevance_score: 0.3, index: 1 }, + ], + }, + { documents: ["a", " "], top_n: 2, return_documents: true } + ); + assert.equal(out.results.length, 2); + assert.equal(out.results[0].index, 0); + assert.equal(out.results[0].document.text, "a"); + assert.equal(out.results[1].index, 1); + assert.equal(out.results[1].document.text, " "); +}); + +test("#7809 voyage response adapter produces Cohere-shaped meta", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformResponseFromProvider( + cfg, + { data: [{ relevance_score: 0.5, index: 0 }] }, + { documents: ["a"] } + ); + assert.ok(out.id.startsWith("rerank-")); + assert.deepEqual(out.meta, { + api_version: { version: "2" }, + billed_units: { search_units: 1 }, + }); +}); + +test("#7809 voyage response adapter handles empty data array", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformResponseFromProvider(cfg, { data: [] }, { documents: ["a", "b"] }); + assert.deepEqual(out.results, []); +});