Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions open-sse/config/rerankRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const RERANK_PROVIDERS = {
baseUrl: "https://api.voyageai.com/v1/rerank",
authType: "apikey",
authHeader: "bearer",
format: "voyage", // Voyage AI is Cohere-compatible except it rejects top_n (#7350)
models: [
{ id: "rerank-2.5", name: "Rerank 2.5" },
{ id: "rerank-2.5-lite", name: "Rerank 2.5 Lite" },
Expand Down
29 changes: 27 additions & 2 deletions open-sse/handlers/rerank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { calculateModalCost } from "@/lib/usage/costCalculator";
import { generateRequestId } from "@/shared/utils/requestId";
import { saveCallLog } from "@/lib/usageDb";
import { resolveProxyForConnection } from "@/lib/db/settings";
import { runWithProxyContext } from "../utils/proxyFetch.ts";

/**
* Build authorization header for a rerank provider
Expand Down Expand Up @@ -47,6 +49,12 @@ function buildAuthHeader(providerConfig, token) {
),
};
}
// Voyage AI is Cohere-compatible except it rejects top_n with HTTP 400 (#7350).
// Strip top_n and pass through everything else.
if (providerConfig.format === "voyage") {
const { top_n: _top_n, ...rest } = body;
return rest;
}
// Default: Cohere-compatible format (used by Together, Fireworks, Cohere, SiliconFlow)
return body;
}
Expand Down Expand Up @@ -107,6 +115,7 @@ function buildAuthHeader(providerConfig, token) {
* @param {number} [options.top_n] - Number of top results to return
* @param {boolean} [options.return_documents] - Whether to include document text in results
* @param {Object} options.credentials - Provider credentials { apiKey, accessToken }
* @param {string} [options.connectionId] - Connection ID for per-connection proxy resolution
* @returns {Response}
*/
/** @returns {Promise<unknown>} */
Expand All @@ -117,6 +126,7 @@ export async function handleRerank({
top_n,
return_documents,
credentials,
connectionId = null,
}) {
const startTime = Date.now();
if (!model) return errorResponse(400, "model is required");
Expand Down Expand Up @@ -156,8 +166,18 @@ export async function handleRerank({
? `${providerConfig.baseUrl}/${modelId}`
: providerConfig.baseUrl;

try {
const res = await fetch(rerankUrl, {
// Resolve per-connection proxy so rerank honors the same proxy pinning as chat
// and embeddings (#7350). Without this, rerank requests egress directly and fail
// when the provider blocks certain IP ranges (e.g. Voyage AI from Russian IPs).
let proxyInfo: Awaited<ReturnType<typeof resolveProxyForConnection>> | null = null;
if (connectionId) {
try {
proxyInfo = await resolveProxyForConnection(connectionId);
} catch {}
}

const doFetch = () =>
fetch(rerankUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand All @@ -166,6 +186,11 @@ export async function handleRerank({
body: JSON.stringify(requestBody),
});

try {
const res = connectionId
? await runWithProxyContext(proxyInfo?.proxy || null, doFetch)
: await doFetch();

if (!res.ok) {
const errData = await res.json().catch(() => ({}));
return errorResponse(
Expand Down
1 change: 1 addition & 0 deletions src/app/api/v1/rerank/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ async function postHandler(request, context) {
top_n: body.top_n,
return_documents: body.return_documents,
credentials,
connectionId: (credentials as { connectionId?: string } | null)?.connectionId || null,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/api/modelTestRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,8 @@ export async function runSingleModelTest(
const runInner = async (signal: AbortSignal): Promise<Response> => {
if (isEmbedding) {
return handleValidatedEmbeddingRequestBody(
testBody as Record<string, unknown> & { model: string }
testBody as Record<string, unknown> & { model: string },
{ connectionId: connectionId || undefined }
);
}
if (isRerank) {
Expand Down
55 changes: 49 additions & 6 deletions tests/unit/rerank-providers-5332.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";

const { parseRerankModel, getAllRerankModels, getRerankProvider } = await import(
"../../open-sse/config/rerankRegistry.ts"
);
const { transformResponseFromProvider, transformRequestForProvider } = await import(
"../../open-sse/handlers/rerank.ts"
);
const { parseRerankModel, getAllRerankModels, getRerankProvider } =
await import("../../open-sse/config/rerankRegistry.ts");
const { transformResponseFromProvider, transformRequestForProvider } =
await import("../../open-sse/handlers/rerank.ts");

test("#5332 parseRerankModel resolves siliconflow multi-slash model id", () => {
assert.deepEqual(parseRerankModel("siliconflow/Qwen/Qwen3-Reranker-8B"), {
Expand Down Expand Up @@ -69,3 +67,48 @@ test("#5332 deepinfra response omits document text when return_documents=false",
assert.equal(out.results[0].document, undefined);
assert.equal(out.results[0].index, 1);
});

// #7350: Voyage AI rejects top_n with HTTP 400 — strip it in the request adapter.
test("#7350 voyage-ai request adapter strips top_n (Voyage API rejects it)", () => {
const cfg = getRerankProvider("voyage-ai");
assert.equal(cfg.format, "voyage");
const out = transformRequestForProvider(cfg, {
model: "rerank-2.5",
query: "q",
documents: ["a", "b"],
top_n: 1,
return_documents: false,
});
assert.equal("top_n" in out, false, "top_n must be stripped for voyage-ai");
assert.equal(out.model, "rerank-2.5");
assert.equal(out.query, "q");
assert.deepEqual(out.documents, ["a", "b"]);
assert.equal(out.return_documents, false);
});

test("#7350 voyage-ai request adapter preserves body when top_n is absent", () => {
const cfg = getRerankProvider("voyage-ai");
const out = transformRequestForProvider(cfg, {
model: "rerank-2.5-lite",
query: "q",
documents: ["a"],
return_documents: true,
});
assert.deepEqual(out, {
model: "rerank-2.5-lite",
query: "q",
documents: ["a"],
return_documents: true,
});
});

test("#7350 voyage-ai is registered with format=voyage", () => {
const cfg = getRerankProvider("voyage-ai");
assert.equal(cfg.format, "voyage");
assert.equal(cfg.baseUrl, "https://api.voyageai.com/v1/rerank");
});

test("#7350 voyage alias resolves to voyage-ai with format=voyage", () => {
const cfg = getRerankProvider("voyage");
assert.equal(cfg?.format, "voyage");
});
Loading