From 01ab5d1fd5e2986f6c482647136a69a4ed7b5c36 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:28:51 -0300 Subject: [PATCH 01/10] =?UTF-8?q?chore(ci):=20add=20.mergify.yml=20to=20ma?= =?UTF-8?q?in=20=E2=80=94=20Mergify=20only=20reads=20config=20from=20the?= =?UTF-8?q?=20default=20branch=20(#7168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .mergify.yml | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .mergify.yml diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 000000000000..131c6d71a990 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,55 @@ +# Mergify merge queue — WS3.4/D5 of the v3.8.49 quality/velocity master plan. +# +# WHY: ~85-100 active PR authors/month and 300+ PRs/week peaks, all merged by ONE +# identity. The manual merge-train validated batches by hand; this queue automates +# it with batching + automatic batch bisection (a red batch of N costs ~log2(N) +# revalidations instead of N). Mergify Open Source plan: free, unlimited, public repo. +# +# GOVERNANCE (non-negotiable, mirrors CLAUDE.md Hard Rules #21/#22 + the owner's +# pre-merge ⭐ gate): +# • A PR enters the queue ONLY via the `queue` label — applied by the owner (or a +# session acting for the owner) AFTER the pre-merge ⭐ report/decision. The label +# IS the merge approval; Mergify only executes it. +# • During a release-freeze (open issue labeled `release-freeze`), do NOT label PRs +# targeting the frozen branch — the freeze is a human-honored coordination signal +# the queue cannot see. Retarget to the active release/vX+1 first (Hard Rule #21). +# • Never label a PR another session is actively working (Hard Rule #22b). +# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual +# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand. + +queue_rules: + - name: release + # Any current or future release branch — the reason GitHub's native queue was + # rejected (no wildcard support on personal-account repos). + queue_conditions: + - base~=^release/v\d+\.\d+\.\d+$ + - label=queue + - -draft + - -conflict + # "Everything that ran is green, nothing still running, AND the always-on + # anchor check succeeded" — robust to the path-filtered fast-gates (docs-only + # PRs skip code jobs; matrix shard names vary) while never fail-open: a PR with + # zero checks cannot vacuously merge, because `Merge integrity` runs on EVERY + # non-draft PR (quality.yml) and must be an affirmative success. Review approval + # is intentionally NOT a condition here: the owner-applied `queue` label IS the + # approval in this repo's single-maintainer model (see governance header). + merge_conditions: + - "#check-failure=0" + - "#check-pending=0" + - "#check-success>=1" + - check-success=Merge integrity (changelog + generated skills) + # Batching: validate up to 10 queued PRs together (the manual train's sweet spot); + # don't hold a lone PR hostage waiting for siblings. + batch_size: 10 + batch_max_wait_time: 5 min + # Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects. + merge_method: squash + +pull_request_rules: + - name: clean up the queue label after merge + conditions: + - merged + actions: + label: + remove: + - queue From fb24740b73443c33e5e6f7ee8b781fa92e5e4cc1 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:11:35 -0300 Subject: [PATCH 02/10] fix(ci): add the auto-enqueue pull_request_rule to the Mergify config (queue_conditions alone are eligibility-only) (#7179) --- .mergify.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.mergify.yml b/.mergify.yml index 131c6d71a990..88bc431369e7 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -46,6 +46,18 @@ queue_rules: merge_method: squash pull_request_rules: + # AUTO-ENQUEUE trigger — queue_conditions alone only define ELIGIBILITY in current + # Mergify semantics (without this rule the PR sits at "use @Mergifyio queue", + # observed live on PR #7175). The conditions mirror queue_conditions on purpose. + - name: queue on owner-applied label + conditions: + - base~=^release/v\d+\.\d+\.\d+$ + - label=queue + - -draft + - -conflict + actions: + queue: + name: release - name: clean up the queue label after merge conditions: - merged From fb25ebdeb0dbd924d6c7f95fd284759e3f87d7c2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:49:49 -0300 Subject: [PATCH 03/10] fix(ci): migrate Mergify auto-enqueue to merge_protections_settings.auto_merge_conditions (rules-based path is EOL 2026-07-16) (#7216) --- .mergify.yml | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 88bc431369e7..d7eb728c0fd4 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -17,6 +17,13 @@ # • Fallback path if Mergify misbehaves or the OSS plan changes: the manual # merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand. +# Auto-enqueue (current Mergify model, 2026): auto_merge_conditions in +# merge_protections_settings — the rules-based queue action / autoqueue path is +# deprecated (EOL 2026-07-16). The owner-applied `queue` label IS the approval. +merge_protections_settings: + auto_merge_conditions: + - label = queue + queue_rules: - name: release # Any current or future release branch — the reason GitHub's native queue was @@ -46,18 +53,6 @@ queue_rules: merge_method: squash pull_request_rules: - # AUTO-ENQUEUE trigger — queue_conditions alone only define ELIGIBILITY in current - # Mergify semantics (without this rule the PR sits at "use @Mergifyio queue", - # observed live on PR #7175). The conditions mirror queue_conditions on purpose. - - name: queue on owner-applied label - conditions: - - base~=^release/v\d+\.\d+\.\d+$ - - label=queue - - -draft - - -conflict - actions: - queue: - name: release - name: clean up the queue label after merge conditions: - merged From a7956945355d8b4dbdf9df531f5f53fb4d4d4274 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:16:02 -0300 Subject: [PATCH 04/10] fix(ci): drop Mergify batch settings (batching is a paid-tier feature; free plan queue is serial) (#7220) --- .mergify.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index d7eb728c0fd4..f7cbe19d7ea4 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -45,10 +45,9 @@ queue_rules: - "#check-pending=0" - "#check-success>=1" - check-success=Merge integrity (changelog + generated skills) - # Batching: validate up to 10 queued PRs together (the manual train's sweet spot); - # don't hold a lone PR hostage waiting for siblings. - batch_size: 10 - batch_max_wait_time: 5 min + # NO batching: 'Merge Queue Batch' requires a paid Mergify tier (live finding + # 2026-07-15 — the queue command fails with "Cannot use Merge Queue batch" on + # the free plan). Serial queue (1 PR at a time) still automates the train. # Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects. merge_method: squash From 9875ccf4e62fb5a26e54f712e192647293bf646b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:48:07 -0300 Subject: [PATCH 05/10] fix(ci): merge queue tolerates the advisory dast-smoke failure (its GH-hosted build hang dequeued every attempt) (#7225) --- .mergify.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.mergify.yml b/.mergify.yml index f7cbe19d7ea4..7cc4882127ec 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -41,7 +41,14 @@ queue_rules: # is intentionally NOT a condition here: the owner-applied `queue` label IS the # approval in this repo's single-maintainer model (see governance header). merge_conditions: - - "#check-failure=0" + # "Zero failures" — EXCEPT the advisory dast-smoke: it is continue-on-error by + # design and its GH-hosted CLI build hangs recurrently (drafts #7184/#7221 were + # dequeued solely by it). Any OTHER failure still blocks (anti-fail-open kept). + - or: + - "#check-failure=0" + - and: + - "#check-failure=1" + - check-failure=dast-smoke - "#check-pending=0" - "#check-success>=1" - check-success=Merge integrity (changelog + generated skills) From 0065f1b7f03d0cfa27a42491e92526e73d9b44fd Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:12:29 -0300 Subject: [PATCH 06/10] =?UTF-8?q?test(ci):=20make=20the=20#6634=20selfref?= =?UTF-8?q?=20guard=20hermetic=20=E2=80=94=20main's=20copy=20hard-fails=20?= =?UTF-8?q?every=20PR=20(#7341)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main's copy of this test still does git I/O inside a unit test: const baseSrc = git(['show', 'origin/main:' + FILE]); Runners check out a shallow single ref, so origin/main does not resolve and the test dies with 'fatal: invalid object name origin/main'. Every PR into main fails Unit Tests (7/8) on it — today that is #7313, #7315, #7316, #7334, #7336 and #7337, six PRs red on a defect none of them introduced. #7313 has no other red at all. release/v3.8.49 already carries a fix (2e42b8efc, #7174: try/catch, fetch origin/main on demand, t.skip() when unreachable), but it only reaches main at release time — so main stays broken for the whole cycle. Cherry-picking it would also import a new problem: PR Test Policy classifies t.skip() as a silenced assertion, which we watched it correctly catch on #7300 today. This is the hermetic version instead (ported from #7327, which does the same for the release branch): read the file straight off disk, compare against an empty base so baseTaut/baseExtTaut are 0 — the strictest possible comparison point — and call evaluateMasking() directly. No git ref, no fetch, no skip, nothing the runner's checkout depth can break. The #6634 regression stays covered: the guard's logic lives in SELF_TEST_FIXTURE_RE (check-test-masking.mjs:337), not in the test. Proven both ways on main before committing — neutralise SELF_TEST_FIXTURE_RE to /$^/ and the test FAILS; restore it and it passes 2/2, with check-test-masking.mjs left byte-identical. Co-authored-by: growab --- .../check-test-masking-selfref-6634.test.ts | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/unit/check-test-masking-selfref-6634.test.ts b/tests/unit/check-test-masking-selfref-6634.test.ts index 6d91a2e7c21f..b233a008f220 100644 --- a/tests/unit/check-test-masking-selfref-6634.test.ts +++ b/tests/unit/check-test-masking-selfref-6634.test.ts @@ -14,12 +14,14 @@ * (`if (file.endsWith("check-test-masking.test.ts")) continue;` in * scripts/check/check-test-masking.mjs) for precisely this reason — this test * asserts evaluateMasking() now applies the same exclusion for its diff-based - * tautology counters, using the real base(origin/main)/head(HEAD) diff of + * tautology counters, against the REAL current source of * tests/unit/check-test-masking.test.ts. */ import test from "node:test"; import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { countTautologies, @@ -28,16 +30,17 @@ import { } from "../../scripts/check/check-test-masking.mjs"; const FILE = "tests/unit/check-test-masking.test.ts"; - -function git(args: string[]): string { - return execFileSync("git", args, { encoding: "utf8" }); -} +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); test("#6634: check-test-masking.test.ts's own tautology fixtures must not self-flag as weakening", () => { - // origin/main predates the #6404 fixtures (countBareTautologies/scanBareTautologies - // tests) that legitimately embed tautology-pattern literals as string fixtures. - const baseSrc = git(["show", "origin/main:" + FILE]); - const headSrc = git(["show", "HEAD:" + FILE]); + // Read the REAL current source from disk rather than a git ref: the Unit Tests + // job checks out a shallow/single-ref tree with no origin/main, so `git show + // origin/main:` failed the shard before it ever exercised the masking + // behavior under test. An empty base models the file's pre-#6404 state (no + // fixtures), which maximizes headTaut - baseTaut — the strictest input for the + // exclusion this test asserts. + const baseSrc = ""; + const headSrc = fs.readFileSync(path.join(REPO_ROOT, FILE), "utf8"); const perFile = [ { From ddd6d09cd10d7a2d6af793a0bfe20d96c05db25c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:47:07 -0300 Subject: [PATCH 07/10] chore(quality): tighten main's coverage baseline to the CI's real numbers (#7347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main's ratchet had been failing --require-tighten on every PR: 11 metrics improved but the baseline was never tightened. Same class as the #6634 selfref guard — an infra fix that lands only on the release branch leaves main red for the whole cycle, and every PR into main pays for it. Values are the merged-coverage numbers from a run on main itself (a local run measures ~68% vs CI's ~80%; the baseline's own note warns about that gap). Only the 11 coverage values change — gitleaks and semgrepFindings keep main's own state. No changelog fragment: #7326 carries it on release/v3.8.49, and a second one here would double the entry at release time. --- config/quality/quality-baseline.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index f660fc33d307..943b0fa4b88a 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -27,22 +27,22 @@ "eps": 0 }, "coverage.statements": { - "value": 76.5, + "value": 80.8, "direction": "up", "tightenSlack": 5 }, "coverage.lines": { - "value": 76.5, + "value": 80.8, "direction": "up", "tightenSlack": 5 }, "coverage.functions": { - "value": 82, + "value": 86.44, "direction": "up", "tightenSlack": 5 }, "coverage.branches": { - "value": 73, + "value": 78.1, "direction": "up", "eps": 1.5, "tightenSlack": 5 @@ -54,43 +54,43 @@ "tightenSlack": 10 }, "coverage.combo.lines": { - "value": 80, + "value": 85.42, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.accountFallback.lines": { - "value": 88, + "value": 96.78, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.auth.lines": { - "value": 90, + "value": 92.55, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.routeGuard.lines": { - "value": 94, + "value": 98.73, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.error.lines": { - "value": 88, + "value": 92.13, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.publicCreds.lines": { - "value": 92, + "value": 99.07, "direction": "up", "eps": 1.5, "tightenSlack": 10 }, "coverage.circuitBreaker.lines": { - "value": 92, + "value": 95.09, "direction": "up", "eps": 1.5, "tightenSlack": 10 From bf8f20bf82d8ea36fc7c90e5a6ec752abd4f421a Mon Sep 17 00:00:00 2001 From: Kamenka Dzmitry <81700662+kamenkadmitry@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:25:33 +0200 Subject: [PATCH 08/10] fix(rerank): apply per-connection proxy and strip top_n for Voyage AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rerank handler used plain fetch() without runWithProxyContext, so per-connection proxy pinning was silently ignored — rerank requests egressed directly instead of through the configured SOCKS5 proxy. This caused failures for providers that block certain IP ranges (e.g. Voyage AI returns 403 from Russian IPs). Additionally, Voyage AI's rerank API rejects the top_n parameter with HTTP 400 ("Argument 'top_n' is not supported by our API"). The default Cohere-compatible request adapter passed top_n through unconditionally. A new "voyage" format strips top_n while preserving all other fields. Also fixes modelTestRunner to pass connectionId to the embeddings handler during test-all, matching the pattern already used by the embeddings route for proxy resolution. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- open-sse/config/rerankRegistry.ts | 1 + open-sse/handlers/rerank.ts | 29 ++++++++++++- src/app/api/v1/rerank/route.ts | 1 + src/lib/api/modelTestRunner.ts | 3 +- tests/unit/rerank-providers-5332.test.ts | 55 +++++++++++++++++++++--- 5 files changed, 80 insertions(+), 9 deletions(-) diff --git a/open-sse/config/rerankRegistry.ts b/open-sse/config/rerankRegistry.ts index 7b8a79801c58..e189ab77949f 100644 --- a/open-sse/config/rerankRegistry.ts +++ b/open-sse/config/rerankRegistry.ts @@ -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" }, diff --git a/open-sse/handlers/rerank.ts b/open-sse/handlers/rerank.ts index 6200a168ac70..648375e841ff 100644 --- a/open-sse/handlers/rerank.ts +++ b/open-sse/handlers/rerank.ts @@ -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 @@ -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; } @@ -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} */ @@ -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"); @@ -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> | null = null; + if (connectionId) { + try { + proxyInfo = await resolveProxyForConnection(connectionId); + } catch {} + } + + const doFetch = () => + fetch(rerankUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -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( diff --git a/src/app/api/v1/rerank/route.ts b/src/app/api/v1/rerank/route.ts index a2f60bb62379..9bd7faf36a71 100644 --- a/src/app/api/v1/rerank/route.ts +++ b/src/app/api/v1/rerank/route.ts @@ -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); diff --git a/src/lib/api/modelTestRunner.ts b/src/lib/api/modelTestRunner.ts index 87a512fa30e4..efc459de10ef 100644 --- a/src/lib/api/modelTestRunner.ts +++ b/src/lib/api/modelTestRunner.ts @@ -257,7 +257,8 @@ export async function runSingleModelTest( const runInner = async (signal: AbortSignal): Promise => { if (isEmbedding) { return handleValidatedEmbeddingRequestBody( - testBody as Record & { model: string } + testBody as Record & { model: string }, + { connectionId: connectionId || undefined } ); } if (isRerank) { diff --git a/tests/unit/rerank-providers-5332.test.ts b/tests/unit/rerank-providers-5332.test.ts index 20a0ad74ef60..88aae6f3b726 100644 --- a/tests/unit/rerank-providers-5332.test.ts +++ b/tests/unit/rerank-providers-5332.test.ts @@ -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"), { @@ -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"); +}); From bf3baa4595fa121da1f77e796f0dd0da09596221 Mon Sep 17 00:00:00 2001 From: Kamenka Dzmitry <81700662+kamenkadmitry@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:53:59 +0200 Subject: [PATCH 09/10] =?UTF-8?q?fix(rerank):=20map=20top=5Fn=E2=86=92top?= =?UTF-8?q?=5Fk=20and=20add=20voyage=20response=20transformer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on #7350: - Voyage AI uses top_k, not top_n — map top_n→top_k instead of stripping - Voyage AI returns {data:[…]} with document as raw string — add response transformer to map to Cohere format ({results:[…]} with document as {text:string} and standard meta block) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- open-sse/handlers/rerank.ts | 29 +++++++++++-- tests/unit/rerank-providers-5332.test.ts | 52 +++++++++++++++++++++++- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/open-sse/handlers/rerank.ts b/open-sse/handlers/rerank.ts index 648375e841ff..6952200724c6 100644 --- a/open-sse/handlers/rerank.ts +++ b/open-sse/handlers/rerank.ts @@ -49,11 +49,14 @@ 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. + // Voyage AI is Cohere-compatible except it uses top_k instead of top_n (#7350). + // Map top_n → top_k so the user's requested limit is preserved. if (providerConfig.format === "voyage") { - const { top_n: _top_n, ...rest } = body; - return rest; + const { top_n, ...rest } = body; + return { + ...rest, + ...(typeof top_n === "number" && top_n > 0 ? { top_k: top_n } : {}), + }; } // Default: Cohere-compatible format (used by Together, Fireworks, Cohere, SiliconFlow) return body; @@ -102,6 +105,24 @@ function buildAuthHeader(providerConfig, token) { }, }; } + // Voyage AI returns {data:[…]} (not results[]), each item has document as a raw string + // (not {text:string}), and no Cohere-style meta block. Map to Cohere format (#7350). + if (providerConfig.format === "voyage") { + const returnDocuments = options.return_documents !== false; + const results = (data.data || data.results || []).map((r) => ({ + index: r.index, + relevance_score: r.relevance_score || 0, + ...(returnDocuments && r.document != null ? { document: { text: String(r.document) } } : {}), + })); + return { + id: data.id != null ? String(data.id) : `rerank-${Date.now()}`, + results, + meta: { + api_version: { version: "2" }, + billed_units: { search_units: 1 }, + }, + }; + } return data; } diff --git a/tests/unit/rerank-providers-5332.test.ts b/tests/unit/rerank-providers-5332.test.ts index 88aae6f3b726..f4df27f21a25 100644 --- a/tests/unit/rerank-providers-5332.test.ts +++ b/tests/unit/rerank-providers-5332.test.ts @@ -68,8 +68,8 @@ test("#5332 deepinfra response omits document text when return_documents=false", 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)", () => { +// #7350: Voyage AI uses top_k instead of top_n — map top_n → top_k in the request adapter. +test("#7350 voyage-ai request adapter maps top_n → top_k (Voyage API uses top_k)", () => { const cfg = getRerankProvider("voyage-ai"); assert.equal(cfg.format, "voyage"); const out = transformRequestForProvider(cfg, { @@ -80,6 +80,7 @@ test("#7350 voyage-ai request adapter strips top_n (Voyage API rejects it)", () return_documents: false, }); assert.equal("top_n" in out, false, "top_n must be stripped for voyage-ai"); + assert.equal(out.top_k, 1, "top_n must be mapped to top_k for voyage-ai"); assert.equal(out.model, "rerank-2.5"); assert.equal(out.query, "q"); assert.deepEqual(out.documents, ["a", "b"]); @@ -102,6 +103,53 @@ test("#7350 voyage-ai request adapter preserves body when top_n is absent", () = }); }); +// #7350: Voyage AI returns {data:[…]} with document as raw string — map to Cohere format. +test("#7350 voyage-ai response adapter maps data[] → results[] with document as {text}", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformResponseFromProvider( + cfg, + { + object: "list", + data: [ + { relevance_score: 0.765625, index: 0, document: "OmniRoute routes AI requests" }, + { relevance_score: 0.34765625, index: 1, document: "Unrelated text" }, + ], + model: "rerank-2.5", + usage: { total_tokens: 12 }, + }, + { return_documents: true } + ); + assert.equal(out.results.length, 2); + assert.equal(out.results[0].index, 0); + assert.equal(out.results[0].relevance_score, 0.765625); + assert.deepEqual(out.results[0].document, { text: "OmniRoute routes AI requests" }); + assert.equal(out.results[1].index, 1); + assert.deepEqual(out.results[1].document, { text: "Unrelated text" }); + assert.ok(out.id, "response should have an id"); + assert.ok(out.meta?.billed_units?.search_units, "response should have Cohere-style meta"); +}); + +test("#7350 voyage-ai response adapter omits document when return_documents=false", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformResponseFromProvider( + cfg, + { + object: "list", + data: [ + { relevance_score: 0.765625, index: 0 }, + { relevance_score: 0.34765625, index: 1 }, + ], + model: "rerank-2.5", + usage: { total_tokens: 12 }, + }, + { return_documents: false } + ); + assert.equal(out.results[0].document, undefined); + assert.equal(out.results[1].document, undefined); + assert.equal(out.results[0].index, 0); + assert.equal(out.results[0].relevance_score, 0.765625); +}); + test("#7350 voyage-ai is registered with format=voyage", () => { const cfg = getRerankProvider("voyage-ai"); assert.equal(cfg.format, "voyage"); From 98e9ac292bbabb5be589876e05cfcb897bfa4893 Mon Sep 17 00:00:00 2001 From: Kamenka Dzmitry <81700662+kamenkadmitry@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:30:12 +0200 Subject: [PATCH 10/10] fix(rerank): log proxy resolution failures + add changelog fragment Address maintainer feedback on #7420: - Add log.error when resolveProxyForConnection fails (was silently swallowed) - Add changelog.d/fixes/7420-rerank-proxy-voyage-topn.md fragment Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- changelog.d/fixes/7420-rerank-proxy-voyage-topn.md | 1 + open-sse/handlers/rerank.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/7420-rerank-proxy-voyage-topn.md diff --git a/changelog.d/fixes/7420-rerank-proxy-voyage-topn.md b/changelog.d/fixes/7420-rerank-proxy-voyage-topn.md new file mode 100644 index 000000000000..c8ee1fca1ca0 --- /dev/null +++ b/changelog.d/fixes/7420-rerank-proxy-voyage-topn.md @@ -0,0 +1 @@ +- **fix(rerank):** rerank handler now honors per-connection proxy via `runWithProxyContext` (was making direct `fetch` calls, bypassing SOCKS5 proxy pinning) and maps `top_n`→`top_k` for Voyage AI with a Cohere-compatible response transformer ([#7420](https://github.com/diegosouzapw/OmniRoute/pull/7420)) — thanks @kamenkadmitry diff --git a/open-sse/handlers/rerank.ts b/open-sse/handlers/rerank.ts index 6952200724c6..3206692a6fd8 100644 --- a/open-sse/handlers/rerank.ts +++ b/open-sse/handlers/rerank.ts @@ -14,6 +14,7 @@ import { generateRequestId } from "@/shared/utils/requestId"; import { saveCallLog } from "@/lib/usageDb"; import { resolveProxyForConnection } from "@/lib/db/settings"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; +import * as log from "@/sse/utils/logger"; /** * Build authorization header for a rerank provider @@ -194,7 +195,9 @@ export async function handleRerank({ if (connectionId) { try { proxyInfo = await resolveProxyForConnection(connectionId); - } catch {} + } catch (err) { + log.error("RERANK", `Proxy resolution failed for connection ${connectionId}: ${err}`); + } } const doFetch = () =>