From afd68eec71b20bc16707cd20cc3db470f5427cf1 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 23:05:40 -0300 Subject: [PATCH 1/2] feat(search): extend /api/search/providers with fetch providers + status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds 3 fetch providers (firecrawl, jina-reader, tavily-search) to the catalog with kind='fetch', costPerQuery, freeMonthlyQuota, and fetchFormats metadata - Replaces raw SQL credential lookup with getProviderCredentials() + isAllRateLimited respecting SEARCH_CREDENTIAL_FALLBACKS (e.g. perplexity-search → perplexity) - Status field: 'configured' | 'missing' | 'rate_limited' per provider - Validates response against SearchProviderCatalogResponseSchema (defensive, non-blocking) - Back-compat: legacy `data` array preserved alongside new `providers` array - Errors routed through buildErrorBody (Hard Rule #12) --- src/app/api/search/providers/route.ts | 221 ++++++++++++++++++++++---- 1 file changed, 186 insertions(+), 35 deletions(-) diff --git a/src/app/api/search/providers/route.ts b/src/app/api/search/providers/route.ts index 87c6741c56..0d85618dae 100644 --- a/src/app/api/search/providers/route.ts +++ b/src/app/api/search/providers/route.ts @@ -3,47 +3,198 @@ import { SEARCH_PROVIDERS, SEARCH_CREDENTIAL_FALLBACKS, } from "@omniroute/open-sse/config/searchRegistry.ts"; -import { getDbInstance } from "@/lib/db/core"; import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { getProviderCredentials } from "@/sse/services/auth"; +import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; +import { + SearchProviderCatalogResponseSchema, + type SearchProviderCatalogItem, +} from "@/shared/schemas/searchTools"; +import * as log from "@/sse/utils/logger"; + +// --------------------------------------------------------------------------- +// Fetch provider metadata (hardcoded — no registry for these 3) +// --------------------------------------------------------------------------- + +interface FetchProviderDef { + id: string; + name: string; + costPerQuery: number; + freeMonthlyQuota: number; + fetchFormats: string[]; +} + +const FETCH_PROVIDERS: FetchProviderDef[] = [ + { + id: "firecrawl", + name: "Firecrawl", + costPerQuery: 0.002, + freeMonthlyQuota: 500, + fetchFormats: ["markdown", "html", "links", "screenshot"], + }, + { + id: "jina-reader", + name: "Jina Reader", + costPerQuery: 0.0005, + freeMonthlyQuota: 1000, + fetchFormats: ["markdown", "text"], + }, + { + id: "tavily-search", + name: "Tavily Extract", + costPerQuery: 0.001, + freeMonthlyQuota: 1000, + fetchFormats: ["markdown", "text"], + }, +]; + +// --------------------------------------------------------------------------- +// Credential status resolution +// --------------------------------------------------------------------------- + +type ProviderStatus = "configured" | "missing" | "rate_limited"; + +/** + * Determine credential status for a provider (search or fetch). + * - "configured" : at least one active key is available + * - "rate_limited": all keys exist but are currently rate-limited + * - "missing" : no credentials found + * + * Respects SEARCH_CREDENTIAL_FALLBACKS (e.g. perplexity-search → perplexity). + */ +async function resolveProviderStatus( + providerId: string, + useCredentialFallback = true +): Promise { + try { + const credentials = await getProviderCredentials(providerId).catch(() => null); + + // Active credentials available + if (credentials && !isAllRateLimitedCredentials(credentials)) { + return "configured"; + } + + // All rate limited — check fallback before returning rate_limited + if (isAllRateLimitedCredentials(credentials)) { + if (useCredentialFallback) { + const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[providerId]; + if (fallbackId) { + const fallbackCreds = await getProviderCredentials(fallbackId).catch(() => null); + if (fallbackCreds && !isAllRateLimitedCredentials(fallbackCreds)) { + return "configured"; + } + } + } + return "rate_limited"; + } + + // null → no credentials; try fallback + if (useCredentialFallback) { + const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[providerId]; + if (fallbackId) { + const fallbackCreds = await getProviderCredentials(fallbackId).catch(() => null); + if (fallbackCreds && !isAllRateLimitedCredentials(fallbackCreds)) { + return "configured"; + } + if (isAllRateLimitedCredentials(fallbackCreds)) { + return "rate_limited"; + } + } + } + + return "missing"; + } catch { + return "missing"; + } +} + +// --------------------------------------------------------------------------- +// Route handler +// --------------------------------------------------------------------------- export async function GET(request: Request) { if (!(await isAuthenticated(request))) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return NextResponse.json( + buildErrorBody(401, "Unauthorized"), + { status: 401 } + ); } - try { - const db = getDbInstance(); - const providers = Object.values(SEARCH_PROVIDERS).map((p) => { - let status: "active" | "no_credentials" = "no_credentials"; - try { - const cred = db - .prepare( - "SELECT id FROM provider_connections WHERE provider = ? AND is_active = 1 LIMIT 1" - ) - .get(p.id); - // Use canonical fallback mapping (e.g. perplexity-search → perplexity) - const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[p.id]; - const fallbackCred = - !cred && fallbackId - ? db - .prepare( - "SELECT id FROM provider_connections WHERE provider = ? AND is_active = 1 LIMIT 1" - ) - .get(fallbackId) - : null; - if (cred || fallbackCred) status = "active"; - } catch { - // DB error — report as no_credentials - } - return { - id: p.id, - name: p.name, - status, - cost_per_query: p.costPerQuery, - }; - }); - return NextResponse.json({ providers }); + try { + const timestamp = Math.floor(Date.now() / 1000); + + // ----------------------------------------------------------------------- + // 1. Build search providers (12 from registry) + // ----------------------------------------------------------------------- + const searchProviderStatuses = await Promise.all( + Object.values(SEARCH_PROVIDERS).map((p) => + resolveProviderStatus(p.id).then((status) => ({ p, status })) + ) + ); + + const searchItems: SearchProviderCatalogItem[] = searchProviderStatuses.map(({ p, status }) => ({ + id: p.id, + name: p.name, + kind: "search" as const, + costPerQuery: p.costPerQuery, + freeMonthlyQuota: p.freeMonthlyQuota, + searchTypes: p.searchTypes, + status, + configureHref: "/dashboard/providers", + })); + + // ----------------------------------------------------------------------- + // 2. Build fetch providers (3 hardcoded) + // ----------------------------------------------------------------------- + const fetchProviderStatuses = await Promise.all( + FETCH_PROVIDERS.map((fp) => + resolveProviderStatus(fp.id, false).then((status) => ({ fp, status })) + ) + ); + + const fetchItems: SearchProviderCatalogItem[] = fetchProviderStatuses.map(({ fp, status }) => ({ + id: fp.id, + name: fp.name, + kind: "fetch" as const, + costPerQuery: fp.costPerQuery, + freeMonthlyQuota: fp.freeMonthlyQuota, + fetchFormats: fp.fetchFormats, + status, + configureHref: "/dashboard/providers", + })); + + // ----------------------------------------------------------------------- + // 3. Combine: search first, then fetch + // ----------------------------------------------------------------------- + const providers: SearchProviderCatalogItem[] = [...searchItems, ...fetchItems]; + + // ----------------------------------------------------------------------- + // 4. Defensive schema validation — log warning but still return on failure + // ----------------------------------------------------------------------- + const parseResult = SearchProviderCatalogResponseSchema.safeParse({ providers }); + if (!parseResult.success) { + log.warn( + "SEARCH_PROVIDERS", + `Response schema validation warning: ${parseResult.error.message}` + ); + } + + // ----------------------------------------------------------------------- + // 5. Back-compat: include legacy `data` array for callers using old shape + // Old shape: { id, object, created, name, search_types } + // ----------------------------------------------------------------------- + const data = providers.map((p) => ({ + id: p.id, + object: "search_provider", + created: timestamp, + name: p.name, + search_types: p.searchTypes ?? [], + })); + + return NextResponse.json({ providers, data }); } catch (error) { - return NextResponse.json({ error: "Failed to list providers" }, { status: 500 }); + log.error("SEARCH_PROVIDERS", "Failed to list providers", error); + return NextResponse.json(buildErrorBody(500, "Failed to list providers"), { status: 500 }); } } From d380883a6e4f156057552817327111908ab09d41 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 23:05:48 -0300 Subject: [PATCH 2/2] test(search): integration coverage for providers catalog - 14 tests covering all status transitions (configured/missing/rate_limited) - Validates 12 search + 3 fetch = 15 provider total count - Asserts kind field correctness per provider type - Tests back-compat data array with legacy {id,object,created,name,search_types} shape - Tests perplexity-search credential fallback to perplexity - Validates response against SearchProviderCatalogResponseSchema (Zod) - Tests 401 behavior when auth is required --- .../search-providers-catalog.test.ts | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 tests/integration/search-providers-catalog.test.ts diff --git a/tests/integration/search-providers-catalog.test.ts b/tests/integration/search-providers-catalog.test.ts new file mode 100644 index 0000000000..ba62e2f2d3 --- /dev/null +++ b/tests/integration/search-providers-catalog.test.ts @@ -0,0 +1,397 @@ +/** + * Integration tests for GET /api/search/providers — extended catalog (F4). + * + * Tests: + * - Returns 15 items total (12 search + 3 fetch providers). + * - Each item carries the correct `kind` field. + * - Status reflects actual DB credential state: + * - "configured" when an active, non-rate-limited connection exists. + * - "missing" when no connection exists for the provider. + * - "rate_limited" when all connections are rate-limited (rateLimitedUntil in future). + * - Back-compat: `data` field is present and uses legacy shape + * {id, object, created, name, search_types}. + * - Unauthenticated requests receive 401. + * - Error responses do not leak stack traces (Hard Rule #12). + */ + +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"; +import { + createManagementSessionHeaders, + TEST_MANAGEMENT_JWT_SECRET, +} from "../helpers/managementSession.ts"; + +// --------------------------------------------------------------------------- +// Isolated temp DB for this test suite +// --------------------------------------------------------------------------- +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-search-providers-catalog-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-api-key-secret-search-catalog"; +// Disable dashboard password requirement by default +process.env.INITIAL_PASSWORD = ""; +process.env.DASHBOARD_PASSWORD = ""; +process.env.JWT_SECRET = TEST_MANAGEMENT_JWT_SECRET; + +// --------------------------------------------------------------------------- +// Module imports (after env setup so DB initialises in the right dir) +// --------------------------------------------------------------------------- +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); + +// Import route AFTER env is configured +const route = await import("../../src/app/api/search/providers/route.ts"); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const EXPECTED_SEARCH_COUNT = 12; +const EXPECTED_FETCH_COUNT = 3; +const EXPECTED_TOTAL = EXPECTED_SEARCH_COUNT + EXPECTED_FETCH_COUNT; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a Request with valid management session cookie. */ +async function buildAuthRequest(url = "http://localhost/api/search/providers"): Promise { + const headers = await createManagementSessionHeaders(); + return new Request(url, { method: "GET", headers }); +} + +/** Build an unauthenticated request. */ +function buildUnauthRequest(url = "http://localhost/api/search/providers"): Request { + return new Request(url, { method: "GET" }); +} + +/** Seed an active provider connection. */ +async function seedActiveConnection(provider: string) { + return providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: `${provider}-test`, + apiKey: `sk-test-${provider}-${Date.now()}`, + isActive: true, + testStatus: "active", + rateLimitedUntil: null, + providerSpecificData: {}, + }); +} + +/** Seed a rate-limited provider connection (rateLimitedUntil in future). */ +async function seedRateLimitedConnection(provider: string) { + const future = new Date(Date.now() + 60_000).toISOString(); + return providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: `${provider}-ratelimited`, + apiKey: `sk-test-${provider}-rl-${Date.now()}`, + isActive: false, // rate-limited connections are set inactive + testStatus: "unavailable", + rateLimitedUntil: future, + providerSpecificData: {}, + }); +} + +/** Reset DB state between tests. */ +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +// --------------------------------------------------------------------------- +// Lifecycle hooks +// --------------------------------------------------------------------------- + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test("search-providers-catalog: returns 401 for unauthenticated requests when auth is required", async () => { + // Enable auth: set a password in DB settings and INITIAL_PASSWORD so isAuthRequired() → true + const settingsDb = await import("../../src/lib/db/settings.ts"); + await settingsDb.updateSettings({ requireLogin: true, password: "hashed-pw-test" }); + + const req = buildUnauthRequest(); + const res = await route.GET(req); + + assert.equal(res.status, 401); + const body = await res.json(); + // Hard Rule #12: error body should not leak stack traces + const bodyStr = JSON.stringify(body); + assert.ok(!bodyStr.includes(" at /"), "error body must not contain stack trace"); +}); + +test("search-providers-catalog: returns 15 providers (12 search + 3 fetch)", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + + assert.equal(res.status, 200); + const body = await res.json(); + + assert.ok(Array.isArray(body.providers), "`providers` array must be present"); + assert.equal( + body.providers.length, + EXPECTED_TOTAL, + `Expected ${EXPECTED_TOTAL} providers, got ${body.providers.length}` + ); +}); + +test("search-providers-catalog: correct count of search and fetch kinds", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const searchItems = body.providers.filter((p: { kind: string }) => p.kind === "search"); + const fetchItems = body.providers.filter((p: { kind: string }) => p.kind === "fetch"); + + assert.equal( + searchItems.length, + EXPECTED_SEARCH_COUNT, + `Expected ${EXPECTED_SEARCH_COUNT} search-kind items, got ${searchItems.length}` + ); + assert.equal( + fetchItems.length, + EXPECTED_FETCH_COUNT, + `Expected ${EXPECTED_FETCH_COUNT} fetch-kind items, got ${fetchItems.length}` + ); +}); + +test("search-providers-catalog: every item has a valid kind value", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + for (const item of body.providers) { + assert.ok( + item.kind === "search" || item.kind === "fetch", + `item.kind must be 'search' or 'fetch', got '${item.kind}' for id=${item.id}` + ); + } +}); + +test("search-providers-catalog: status=missing when no DB credentials exist", async () => { + // No connections seeded — all providers should be "missing" + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + for (const item of body.providers) { + assert.equal( + item.status, + "missing", + `provider ${item.id} should be 'missing', got '${item.status}'` + ); + } +}); + +test("search-providers-catalog: status=configured when active credentials seeded", async () => { + // Seed an active connection for serper-search + await seedActiveConnection("serper-search"); + + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const serper = body.providers.find((p: { id: string }) => p.id === "serper-search"); + assert.ok(serper, "serper-search must be in response"); + assert.equal( + serper.status, + "configured", + `serper-search should be 'configured' after seeding active credentials` + ); + + // Other providers (no creds) should still be "missing" + const missingItems = body.providers.filter( + (p: { id: string; status: string }) => p.id !== "serper-search" && p.id !== "perplexity-search" + ); + for (const item of missingItems) { + assert.equal(item.status, "missing", `${item.id} should be 'missing'`); + } +}); + +test("search-providers-catalog: status=rate_limited when all connections are rate-limited", async () => { + // Seed a rate-limited connection for brave-search (no active connections) + await seedRateLimitedConnection("brave-search"); + + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const brave = body.providers.find((p: { id: string }) => p.id === "brave-search"); + assert.ok(brave, "brave-search must be in response"); + assert.equal( + brave.status, + "rate_limited", + `brave-search should be 'rate_limited' when all connections have future rateLimitedUntil` + ); +}); + +test("search-providers-catalog: mixed status across providers", async () => { + // serper → configured, brave → rate_limited, rest → missing + await seedActiveConnection("serper-search"); + await seedRateLimitedConnection("brave-search"); + + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const serper = body.providers.find((p: { id: string }) => p.id === "serper-search"); + assert.equal(serper?.status, "configured", "serper-search should be configured"); + + const brave = body.providers.find((p: { id: string }) => p.id === "brave-search"); + assert.equal(brave?.status, "rate_limited", "brave-search should be rate_limited"); + + const exa = body.providers.find((p: { id: string }) => p.id === "exa-search"); + assert.equal(exa?.status, "missing", "exa-search should be missing"); +}); + +test("search-providers-catalog: back-compat data field has legacy shape", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + // Legacy shape: { id, object, created, name, search_types } + assert.ok(Array.isArray(body.data), "`data` array must be present for back-compat"); + assert.equal( + body.data.length, + EXPECTED_TOTAL, + "data array should have same length as providers" + ); + + for (const item of body.data) { + assert.ok(typeof item.id === "string", "data item must have id"); + assert.equal(item.object, "search_provider", "data item object must be 'search_provider'"); + assert.ok(typeof item.created === "number", "data item must have numeric created timestamp"); + assert.ok(typeof item.name === "string", "data item must have name"); + assert.ok(Array.isArray(item.search_types), "data item must have search_types array"); + } +}); + +test("search-providers-catalog: fetch providers have correct metadata", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const fetchProviders = body.providers.filter((p: { kind: string }) => p.kind === "fetch"); + const ids = fetchProviders.map((p: { id: string }) => p.id); + assert.ok(ids.includes("firecrawl"), "firecrawl must be present"); + assert.ok(ids.includes("jina-reader"), "jina-reader must be present"); + assert.ok(ids.includes("tavily-search"), "tavily-search must be present"); + + const firecrawl = fetchProviders.find((p: { id: string }) => p.id === "firecrawl"); + assert.equal(firecrawl.name, "Firecrawl"); + assert.equal(firecrawl.costPerQuery, 0.002); + assert.equal(firecrawl.freeMonthlyQuota, 500); + assert.ok(Array.isArray(firecrawl.fetchFormats), "fetchFormats must be an array"); + assert.ok( + firecrawl.fetchFormats.includes("markdown"), + "firecrawl fetchFormats must include markdown" + ); + assert.ok( + firecrawl.fetchFormats.includes("screenshot"), + "firecrawl fetchFormats must include screenshot" + ); + + const jina = fetchProviders.find((p: { id: string }) => p.id === "jina-reader"); + assert.equal(jina.name, "Jina Reader"); + assert.equal(jina.costPerQuery, 0.0005); + assert.ok(jina.fetchFormats.includes("text"), "jina fetchFormats must include text"); + + const tavily = fetchProviders.find((p: { id: string }) => p.id === "tavily-search"); + assert.equal(tavily.name, "Tavily Extract"); + assert.equal(tavily.costPerQuery, 0.001); +}); + +test("search-providers-catalog: search providers have correct fields", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const searchProviders = body.providers.filter((p: { kind: string }) => p.kind === "search"); + for (const item of searchProviders) { + assert.ok(typeof item.id === "string", "search item must have id"); + assert.ok(typeof item.name === "string", "search item must have name"); + assert.ok(typeof item.costPerQuery === "number", "search item must have costPerQuery"); + assert.ok( + typeof item.freeMonthlyQuota === "number", + "search item must have freeMonthlyQuota" + ); + assert.ok(Array.isArray(item.searchTypes), "search item must have searchTypes array"); + assert.equal( + item.configureHref, + "/dashboard/providers", + "configureHref must be /dashboard/providers" + ); + } + + // Spot check: serper-search + const serper = searchProviders.find((p: { id: string }) => p.id === "serper-search"); + assert.ok(serper, "serper-search must be in search providers"); + assert.ok(serper.searchTypes.includes("web"), "serper must support web search"); + assert.equal(serper.kind, "search"); +}); + +test("search-providers-catalog: response validates against SearchProviderCatalogResponseSchema", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const { SearchProviderCatalogResponseSchema } = await import( + "../../src/shared/schemas/searchTools.ts" + ); + + const result = SearchProviderCatalogResponseSchema.safeParse({ providers: body.providers }); + assert.ok( + result.success, + `Schema validation failed: ${(result as { error?: { message: string } }).error?.message ?? "unknown"}` + ); +}); + +test("search-providers-catalog: fetch providers have configureHref set", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const fetchProviders = body.providers.filter((p: { kind: string }) => p.kind === "fetch"); + for (const item of fetchProviders) { + assert.equal( + item.configureHref, + "/dashboard/providers", + `fetch provider ${item.id} must have configureHref='/dashboard/providers'` + ); + } +}); + +test("search-providers-catalog: perplexity-search uses credential fallback", async () => { + // perplexity-search can use perplexity (chat) credentials as fallback + // Seed the fallback provider "perplexity" (not "perplexity-search") + await seedActiveConnection("perplexity"); + + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const perplexity = body.providers.find((p: { id: string }) => p.id === "perplexity-search"); + assert.ok(perplexity, "perplexity-search must be in response"); + assert.equal( + perplexity.status, + "configured", + "perplexity-search should be 'configured' via fallback to perplexity credentials" + ); +});