From a02b4b404d07ea4a53412c97580ba5f165e648cc Mon Sep 17 00:00:00 2001 From: vsd2807 Date: Wed, 26 Aug 2026 16:41:05 +0530 Subject: [PATCH] fix(search): prefer credentialed providers over duckduckgo-free fallback (#11524) (#11565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged via /merge-batch (lote 2026-08-26, v3.8.51). Boarded no worktree combinado junto com outras ~30 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e ~370 testes focados (unit + vitest) passando. Obrigado pela contribuição. --- ...11524-search-credentialed-over-fallback.md | 1 + src/lib/search/executeWebSearch.ts | 44 ++++---- .../execute-web-search-fallback-11524.test.ts | 104 ++++++++++++++++++ 3 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 changelog.d/fixes/11524-search-credentialed-over-fallback.md create mode 100644 tests/unit/execute-web-search-fallback-11524.test.ts diff --git a/changelog.d/fixes/11524-search-credentialed-over-fallback.md b/changelog.d/fixes/11524-search-credentialed-over-fallback.md new file mode 100644 index 0000000000..0d5d889226 --- /dev/null +++ b/changelog.d/fixes/11524-search-credentialed-over-fallback.md @@ -0,0 +1 @@ +- fix(search): prefer credentialed providers over duckduckgo-free fallback (#11524) — the fallback-only loop ran before the credentialed-providers loop in `executeWebSearch`, making configured providers unreachable when `duckduckgo-free` was available. diff --git a/src/lib/search/executeWebSearch.ts b/src/lib/search/executeWebSearch.ts index 0845f8a611..e5c7c5a545 100644 --- a/src/lib/search/executeWebSearch.ts +++ b/src/lib/search/executeWebSearch.ts @@ -186,6 +186,28 @@ export async function executeWebSearch( credentials = await resolveSearchCredentials(providerConfig.id); if (!credentials) { + // 1. Try credentialed providers first, sorted by cost. Fallback-only + // providers are reached only if no configured provider is available. + const sortedIds = Object.values(SEARCH_PROVIDERS) + .filter((provider) => !provider.fallbackOnly && supportsSearchType(provider, searchType)) + .sort((a, b) => a.costPerQuery - b.costPerQuery) + .map((provider) => provider.id); + + for (const providerId of sortedIds) { + if (providerId === providerConfig.id) continue; + const altConfig = getSearchProvider(providerId); + const altCreds = await resolveSearchCredentials(providerId); + if (altConfig && altCreds) { + providerConfig = altConfig; + credentials = altCreds; + break; + } + } + } + + if (!credentials) { + // 2. Last resort: fallback-only providers so out-of-the-box search + // still works when no credentialed provider is configured. const fallbackProviders = Object.values(SEARCH_PROVIDERS) .filter((provider) => provider.fallbackOnly && supportsSearchType(provider, searchType)) .sort((a, b) => a.costPerQuery - b.costPerQuery); @@ -204,24 +226,6 @@ export async function executeWebSearch( } } - if (!credentials) { - const sortedIds = Object.values(SEARCH_PROVIDERS) - .filter((provider) => supportsSearchType(provider, searchType)) - .sort((a, b) => a.costPerQuery - b.costPerQuery) - .map((provider) => provider.id); - - for (const providerId of sortedIds) { - if (providerId === providerConfig.id) continue; - const altConfig = getSearchProvider(providerId); - const altCreds = await resolveSearchCredentials(providerId); - if (altConfig && altCreds) { - providerConfig = altConfig; - credentials = altCreds; - break; - } - } - } - if (!credentials) { throw new WebSearchExecutionError( `No credentials configured for any search provider. Add an API key for a search provider (${Object.keys( @@ -231,8 +235,10 @@ export async function executeWebSearch( ); } + // Exclude fallback-only providers from execution-time alternates. + // They are reserved for last-resort primary selection. const otherIds = Object.values(SEARCH_PROVIDERS) - .filter((provider) => supportsSearchType(provider, searchType)) + .filter((provider) => !provider.fallbackOnly && supportsSearchType(provider, searchType)) .sort((a, b) => a.costPerQuery - b.costPerQuery) .map((provider) => provider.id) .filter((providerId) => providerId !== providerConfig!.id); diff --git a/tests/unit/execute-web-search-fallback-11524.test.ts b/tests/unit/execute-web-search-fallback-11524.test.ts new file mode 100644 index 0000000000..1daf06a94b --- /dev/null +++ b/tests/unit/execute-web-search-fallback-11524.test.ts @@ -0,0 +1,104 @@ +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"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-execute-web-search-fallback-") +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { executeWebSearch } = await import("../../src/lib/search/executeWebSearch.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedConnection( + provider: string, + overrides: { + apiKey?: string | null; + authType?: string; + providerSpecificData?: Record; + } = {} +) { + return providersDb.createProviderConnection({ + provider, + authType: overrides.authType || "apikey", + name: `${provider}-${Math.random().toString(16).slice(2, 8)}`, + apiKey: overrides.apiKey ?? "test-key", + isActive: true, + testStatus: "active", + providerSpecificData: overrides.providerSpecificData || {}, + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// Regression test for #11524 — executeWebSearch must prefer a credentialed +// provider over duckduckgo-free when the initially selected provider has no credentials. +test("auto-selects credentialed provider before duckduckgo-free fallback (#11524)", async () => { + await seedConnection("brave-search", { apiKey: "brave-key" }); + + const originalFetch = globalThis.fetch; + const fetchCalls: string[] = []; + + globalThis.fetch = async (url, _init = {}) => { + const urlStr = String(url); + fetchCalls.push(urlStr); + + if (urlStr.includes("api.search.brave.com")) { + return new Response( + JSON.stringify({ + web: { + results: [ + { + title: "Brave result", + url: "https://example.com/brave", + description: "Brave search result", + }, + ], + totalCount: 1, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + throw new Error(`Unexpected fetch to ${urlStr} in auto-select path`); + }; + + try { + const result = await executeWebSearch({ query: "latest omniroute roadmap" }); + + assert.equal( + result.data.provider, + "brave-search", + "must use the configured credentialed provider, not duckduckgo-free" + ); + assert.ok( + fetchCalls.some((url) => url.includes("api.search.brave.com")), + "must call the Brave Search endpoint" + ); + assert.ok( + !fetchCalls.some((url) => url.includes("duckduckgo.com")), + "duckduckgo-free must NOT be invoked when a credentialed provider is available (#11524)" + ); + assert.equal(result.data.results.length, 1); + assert.equal(result.data.results[0].title, "Brave result"); + } finally { + globalThis.fetch = originalFetch; + } +});