From f9dd967bc5f5c9f267f11af51cb4a1aa85a88c54 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:14:44 +0000 Subject: [PATCH 1/2] deps: bump next from 16.1.6 to 16.1.7 Bumps [next](https://github.com/vercel/next.js) from 16.1.6 to 16.1.7. - [Release notes](https://github.com/vercel/next.js/releases) - [Changelog](https://github.com/vercel/next.js/blob/canary/release.js) - [Commits](https://github.com/vercel/next.js/compare/v16.1.6...v16.1.7) --- updated-dependencies: - dependency-name: next dependency-version: 16.1.7 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 85 +++++++++++++++++++++++------------------------ 1 file changed, 42 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index f5ffb70869..c0482c2ad1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "2.6.10", + "version": "2.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "2.6.10", + "version": "2.7.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -1725,9 +1725,9 @@ } }, "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz", + "integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1741,9 +1741,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz", + "integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==", "cpu": [ "arm64" ], @@ -1757,9 +1757,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz", + "integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==", "cpu": [ "x64" ], @@ -1773,9 +1773,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz", + "integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==", "cpu": [ "arm64" ], @@ -1789,9 +1789,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz", + "integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==", "cpu": [ "arm64" ], @@ -1805,9 +1805,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz", + "integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==", "cpu": [ "x64" ], @@ -1821,9 +1821,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz", + "integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==", "cpu": [ "x64" ], @@ -1837,9 +1837,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz", + "integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==", "cpu": [ "arm64" ], @@ -1853,9 +1853,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz", + "integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==", "cpu": [ "x64" ], @@ -6817,7 +6817,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8812,14 +8811,14 @@ } }, "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz", + "integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==", "license": "MIT", "dependencies": { - "@next/env": "16.1.6", + "@next/env": "16.1.7", "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -8831,14 +8830,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", + "@next/swc-darwin-arm64": "16.1.7", + "@next/swc-darwin-x64": "16.1.7", + "@next/swc-linux-arm64-gnu": "16.1.7", + "@next/swc-linux-arm64-musl": "16.1.7", + "@next/swc-linux-x64-gnu": "16.1.7", + "@next/swc-linux-x64-musl": "16.1.7", + "@next/swc-win32-arm64-msvc": "16.1.7", + "@next/swc-win32-x64-msvc": "16.1.7", "sharp": "^0.34.4" }, "peerDependencies": { From 564e983c68a82529a659b837d3e6059689e7c2ae Mon Sep 17 00:00:00 2001 From: Regis <92858615+Regis-RCR@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:28:35 +0100 Subject: [PATCH 2/2] feat(search): add unified web search routing with 5 providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /v1/search — a unified search endpoint routing queries across 5 providers (Serper, Brave, Perplexity Search, Exa, Tavily) with automatic failover, in-memory caching, and request coalescing. No open-source AI gateway offers unified search routing. This chains free tiers for 5,500+ searches/month with zero downtime. Providers: Serper ($0.001/q, 2500/mo free), Brave ($0.005/q, 1000/mo), Perplexity Search ($0.005/q), Exa ($0.007/q, 1000/mo), Tavily ($0.008/q, 1000/mo). Auto-select picks cheapest with credentials. Architecture follows existing patterns: - searchRegistry.ts (same as embeddingRegistry.ts) - search.ts handler (same as embeddings.ts) - route.ts (same as /v1/embeddings/route.ts) - searchCache.ts (bounded TTL cache + request coalescing) Schema finalized — all future fields defined as optional with safe defaults. No breaking changes when implementing content extraction, answer synthesis, or ranking. Key features: - Per-provider request builders and response normalizers - Enriched response: display_url, score, favicon_url, content block, metadata, answer block, errors array, upstream_latency_ms metrics - Cost-sorted auto-select with failover on 429/5xx/timeout - Credential fallback (perplexity-search reuses perplexity chat key) - Cache key includes all result-affecting parameters - max_results clamped to provider limits, sanitized error responses - Factored validators (validateSearchProvider factory) - CORS headers on all responses - Dashboard: Search & Discovery section, search provider template - DB migration 007: request_type column in call_logs - 28 unit tests (registry, cache, coalescing, validation) --- open-sse/config/searchRegistry.ts | 155 ++++ open-sse/handlers/search.ts | 664 ++++++++++++++++++ open-sse/services/searchCache.ts | 142 ++++ public/providers/brave-search.png | Bin 0 -> 3304 bytes public/providers/brave.png | Bin 0 -> 3304 bytes public/providers/brave.svg | 1 + public/providers/exa-search.png | Bin 0 -> 6768 bytes public/providers/exa-search.svg | 4 + public/providers/exa.svg | 4 + public/providers/perplexity-search.png | Bin 0 -> 7175 bytes public/providers/perplexity.png | Bin 2108 -> 7175 bytes public/providers/serper-search.png | Bin 0 -> 2798 bytes public/providers/serper.png | Bin 0 -> 2798 bytes public/providers/tavily-search.png | Bin 0 -> 1306 bytes public/providers/tavily.png | Bin 0 -> 1306 bytes .../dashboard/endpoint/EndpointPageClient.tsx | 61 +- .../dashboard/providers/[id]/page.tsx | 51 +- src/app/api/v1/search/route.ts | 268 +++++++ src/i18n/messages/en.json | 5 + .../db/migrations/007_search_request_type.sql | 4 + src/lib/providers/validation.ts | 73 ++ src/lib/usage/callLogs.ts | 5 +- src/shared/constants/providers.ts | 52 +- src/shared/validation/schemas.ts | 134 ++++ tests/unit/search-registry.test.mjs | 277 ++++++++ 25 files changed, 1881 insertions(+), 19 deletions(-) create mode 100644 open-sse/config/searchRegistry.ts create mode 100644 open-sse/handlers/search.ts create mode 100644 open-sse/services/searchCache.ts create mode 100644 public/providers/brave-search.png create mode 100644 public/providers/brave.png create mode 100644 public/providers/brave.svg create mode 100644 public/providers/exa-search.png create mode 100644 public/providers/exa-search.svg create mode 100644 public/providers/exa.svg create mode 100644 public/providers/perplexity-search.png create mode 100644 public/providers/serper-search.png create mode 100644 public/providers/serper.png create mode 100644 public/providers/tavily-search.png create mode 100644 public/providers/tavily.png create mode 100644 src/app/api/v1/search/route.ts create mode 100644 src/lib/db/migrations/007_search_request_type.sql create mode 100644 tests/unit/search-registry.test.mjs diff --git a/open-sse/config/searchRegistry.ts b/open-sse/config/searchRegistry.ts new file mode 100644 index 0000000000..4c152571e8 --- /dev/null +++ b/open-sse/config/searchRegistry.ts @@ -0,0 +1,155 @@ +/** + * Search Provider Registry + * + * Defines providers that support the /v1/search endpoint. + * Unlike LLM/embedding providers, search providers don't have "models" — + * a provider IS the model (Serper = Google SERP, Brave = Brave index). + * + * API keys are stored in the same provider credentials system, + * keyed by provider ID (e.g. "serper-search", "brave-search"). + * perplexity-search reuses credentials from the "perplexity" chat provider. + */ + +export interface SearchProviderConfig { + id: string; + name: string; + baseUrl: string; + method: "GET" | "POST"; + authType: "apikey"; + authHeader: string; + costPerQuery: number; + freeMonthlyQuota: number; + searchTypes: string[]; + defaultMaxResults: number; + maxMaxResults: number; + timeoutMs: number; + cacheTTLMs: number; +} + +export const SEARCH_PROVIDERS: Record = { + "serper-search": { + id: "serper-search", + name: "Serper Search", + baseUrl: "https://google.serper.dev", + method: "POST", + authType: "apikey", + authHeader: "x-api-key", + costPerQuery: 0.001, + freeMonthlyQuota: 2500, + searchTypes: ["web", "news"], + defaultMaxResults: 5, + maxMaxResults: 100, + timeoutMs: 10_000, + cacheTTLMs: 5 * 60 * 1000, + }, + + "brave-search": { + id: "brave-search", + name: "Brave Search", + baseUrl: "https://api.search.brave.com/res/v1", + method: "GET", + authType: "apikey", + authHeader: "x-subscription-token", + costPerQuery: 0.005, + freeMonthlyQuota: 1000, + searchTypes: ["web", "news"], + defaultMaxResults: 5, + maxMaxResults: 20, + timeoutMs: 10_000, + cacheTTLMs: 5 * 60 * 1000, + }, + + "perplexity-search": { + id: "perplexity-search", + name: "Perplexity Search", + baseUrl: "https://api.perplexity.ai/search", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0.005, + freeMonthlyQuota: 0, + searchTypes: ["web"], + defaultMaxResults: 5, + maxMaxResults: 20, + timeoutMs: 10_000, + cacheTTLMs: 5 * 60 * 1000, + }, + + "exa-search": { + id: "exa-search", + name: "Exa Search", + baseUrl: "https://api.exa.ai/search", + method: "POST", + authType: "apikey", + authHeader: "x-api-key", + costPerQuery: 0.007, + freeMonthlyQuota: 1000, + searchTypes: ["web", "news"], + defaultMaxResults: 5, + maxMaxResults: 100, + timeoutMs: 10_000, + cacheTTLMs: 5 * 60 * 1000, + }, + + "tavily-search": { + id: "tavily-search", + name: "Tavily Search", + baseUrl: "https://api.tavily.com/search", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0.008, + freeMonthlyQuota: 1000, + searchTypes: ["web", "news"], + defaultMaxResults: 5, + maxMaxResults: 20, + timeoutMs: 10_000, + cacheTTLMs: 5 * 60 * 1000, + }, +}; + +/** + * Credential fallback mapping — search providers that can reuse credentials + * from a related provider (e.g., perplexity-search uses the same API key as perplexity chat). + */ +export const SEARCH_CREDENTIAL_FALLBACKS: Record = { + "perplexity-search": "perplexity", +}; + +/** + * Get search provider config by ID + */ +export function getSearchProvider(providerId: string): SearchProviderConfig | null { + return SEARCH_PROVIDERS[providerId] || null; +} + +/** + * Get all search providers as a flat list + */ +export function getAllSearchProviders(): Array<{ + id: string; + name: string; + searchTypes: string[]; +}> { + return Object.values(SEARCH_PROVIDERS).map((p) => ({ + id: p.id, + name: p.name, + searchTypes: p.searchTypes, + })); +} + +/** + * Select the cheapest available provider. + * If an explicit provider is given, validate and return it. + * Otherwise, return the cheapest by costPerQuery. + */ +export function selectProvider(explicitProvider?: string): SearchProviderConfig | null { + if (explicitProvider) { + return SEARCH_PROVIDERS[explicitProvider] || null; + } + + const providers = Object.values(SEARCH_PROVIDERS); + if (providers.length === 0) return null; + + return providers.reduce((cheapest, p) => (p.costPerQuery < cheapest.costPerQuery ? p : cheapest)); +} diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts new file mode 100644 index 0000000000..ab9a8b2668 --- /dev/null +++ b/open-sse/handlers/search.ts @@ -0,0 +1,664 @@ +/** + * Search Handler + * + * Handles POST /v1/search requests. + * Routes to 5 search providers with automatic failover: + * serper-search, brave-search, perplexity-search, exa-search, tavily-search + * + * Request format: + * { + * "query": "search query", + * "provider": "serper-search" | "brave-search" | ... // optional, auto-selects cheapest + * "max_results": 5, + * "search_type": "web" | "news" + * } + */ + +import { getSearchProvider, type SearchProviderConfig } from "../config/searchRegistry.ts"; +import { saveCallLog } from "@/lib/usageDb"; + +// ── Types ──────────────────────────────────────────────────────────────── + +export interface SearchResult { + title: string; + url: string; + display_url?: string; + snippet: string; + position: number; + score: number | null; + published_at: string | null; + favicon_url: string | null; + content: { format: string; text: string; length: number } | null; + metadata: { + author: string | null; + language: string | null; + source_type: string | null; + image_url: string | null; + } | null; + citation: { + provider: string; + retrieved_at: string; + rank: number; + }; + provider_raw: Record | null; +} + +export interface SearchResponse { + provider: string; + query: string; + results: SearchResult[]; + answer: { source: string; text: string | null; model: string | null } | null; + usage: { queries_used: number; search_cost_usd: number; llm_tokens?: number }; + metrics: { + response_time_ms: number; + upstream_latency_ms: number; + gateway_latency_ms?: number; + total_results_available: number | null; + }; + errors: Array<{ provider: string; code: string; message: string }>; +} + +interface SearchHandlerResult { + success: boolean; + status?: number; + error?: string; + data?: SearchResponse; +} + +interface SearchHandlerOptions { + query: string; + provider: string; + maxResults: number; + searchType: string; + country?: string; + language?: string; + timeRange?: string; + offset?: number; + domainFilter?: string[]; + contentOptions?: { snippet?: boolean; full_page?: boolean; format?: string; max_characters?: number }; + strictFilters?: boolean; + providerOptions?: Record; + credentials: Record; + alternateProvider?: string; + alternateCredentials?: Record | null; + log?: any; +} + +// ── Constants ──────────────────────────────────────────────────────────── + +const GLOBAL_TIMEOUT_MS = 15_000; + +// Non-retriable HTTP status codes — fail immediately, don't try alternate +const NON_RETRIABLE = new Set([400, 401, 403, 404]); + +// ── Input Sanitization ────────────────────────────────────────────────── + +// Control characters that should never appear in search queries +const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/; + +function sanitizeQuery(query: string): { clean: string; error?: string } { + if (CONTROL_CHAR_RE.test(query)) { + return { clean: "", error: "Query contains invalid control characters" }; + } + const clean = query.normalize("NFKC").trim().replace(/\s+/g, " "); + if (clean.length === 0) { + return { clean: "", error: "Query is empty after normalization" }; + } + return { clean }; +} + +// ── Response Normalizers ──────────────────────────────────────────────── + +function makeResult( + providerId: string, + item: { + title?: string; + url?: string; + snippet?: string; + score?: number; + published_at?: string; + favicon_url?: string; + author?: string; + source_type?: string; + image_url?: string; + full_text?: string; + text_format?: string; + }, + idx: number, + now: string +): SearchResult { + const url = item.url || ""; + return { + title: item.title || "", + url, + display_url: url ? url.replace(/^https?:\/\/(www\.)?/, "").split("?")[0] : undefined, + snippet: item.snippet || "", + position: idx + 1, + score: typeof item.score === "number" ? Math.min(1, Math.max(0, item.score)) : null, + published_at: item.published_at || null, + favicon_url: item.favicon_url || null, + content: item.full_text + ? { format: item.text_format || "text", text: item.full_text, length: item.full_text.length } + : null, + metadata: { + author: item.author || null, + language: null, + source_type: item.source_type || null, + image_url: item.image_url || null, + }, + citation: { provider: providerId, retrieved_at: now, rank: idx + 1 }, + provider_raw: null, + }; +} + +function normalizeSerperResponse( + data: any, + _query: string, + searchType: string +): { results: SearchResult[]; totalResults: number | null } { + const now = new Date().toISOString(); + const items = searchType === "news" ? data.news : data.organic; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + + const results = items.map((item: any, idx: number) => + makeResult( + "serper-search", + { + title: item.title, + url: item.link, + snippet: item.snippet || item.description, + published_at: item.date, + }, + idx, + now + ) + ); + + return { + results, + totalResults: + typeof data.searchParameters?.totalResults === "number" + ? data.searchParameters.totalResults + : null, + }; +} + +function normalizeBraveResponse( + data: any, + _query: string, + searchType: string +): { results: SearchResult[]; totalResults: number | null } { + const now = new Date().toISOString(); + const container = searchType === "news" ? data.news : data.web; + const items = container?.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + + const results = items.map((item: any, idx: number) => + makeResult( + "brave-search", + { + title: item.title, + url: item.url, + snippet: item.description, + published_at: item.page_age || item.age, + favicon_url: item.meta_url?.favicon || item.favicon, + }, + idx, + now + ) + ); + + return { results, totalResults: container?.totalCount ?? null }; +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +function parseDomainFilter(domainFilter?: string[]): { + includes: string[]; + excludes: string[]; +} { + if (!domainFilter?.length) return { includes: [], excludes: [] }; + const includes = domainFilter.filter((d) => !d.startsWith("-")); + const excludes = domainFilter.filter((d) => d.startsWith("-")).map((d) => d.slice(1)); + return { includes, excludes }; +} + +// ── Provider Request Builders ─────────────────────────────────────────── + +interface SearchRequestParams { + query: string; + searchType: string; + maxResults: number; + token: string; + country?: string; + language?: string; + domainFilter?: string[]; +} + +function buildSerperRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + const endpoint = params.searchType === "news" ? "/news" : "/search"; + const body: Record = { q: params.query, num: params.maxResults }; + if (params.country) body.gl = params.country.toLowerCase(); + if (params.language) body.hl = params.language; + return { + url: `${config.baseUrl}${endpoint}`, + init: { + method: "POST", + headers: { "Content-Type": "application/json", "X-API-Key": params.token }, + body: JSON.stringify(body), + }, + }; +} + +function buildBraveRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + const endpoint = params.searchType === "news" ? "/news/search" : "/web/search"; + const qp = new URLSearchParams({ q: params.query, count: String(params.maxResults) }); + if (params.country) qp.set("country", params.country); + if (params.language) qp.set("search_lang", params.language); + return { + url: `${config.baseUrl}${endpoint}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json", "X-Subscription-Token": params.token }, + }, + }; +} + +function buildPerplexityRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + const body: Record = { query: params.query, max_results: params.maxResults }; + if (params.country) body.country = params.country; + if (params.language) body.search_language_filter = [params.language]; + if (params.domainFilter?.length) body.search_domain_filter = params.domainFilter; + return { + url: config.baseUrl, + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` }, + body: JSON.stringify(body), + }, + }; +} + +function buildExaRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + const { includes, excludes } = parseDomainFilter(params.domainFilter); + const body: Record = { + query: params.query, + numResults: params.maxResults, + type: "auto", + text: true, + highlights: true, + }; + if (includes.length) body.includeDomains = includes; + if (excludes.length) body.excludeDomains = excludes; + if (params.searchType === "news") body.category = "news"; + return { + url: config.baseUrl, + init: { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": params.token }, + body: JSON.stringify(body), + }, + }; +} + +function buildTavilyRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + const { includes, excludes } = parseDomainFilter(params.domainFilter); + const body: Record = { + query: params.query, + max_results: params.maxResults, + topic: params.searchType === "news" ? "news" : "general", + }; + if (includes.length) body.include_domains = includes; + if (excludes.length) body.exclude_domains = excludes; + if (params.country) body.country = params.country; + return { + url: config.baseUrl, + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` }, + body: JSON.stringify(body), + }, + }; +} + +function buildRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + if (config.id === "serper-search") return buildSerperRequest(config, params); + if (config.id === "brave-search") return buildBraveRequest(config, params); + if (config.id === "perplexity-search") return buildPerplexityRequest(config, params); + if (config.id === "exa-search") return buildExaRequest(config, params); + if (config.id === "tavily-search") return buildTavilyRequest(config, params); + // Fallback for future providers: POST with bearer auth + return { + url: config.baseUrl, + init: { + method: config.method, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` }, + body: JSON.stringify({ + query: params.query, + max_results: params.maxResults, + search_type: params.searchType, + }), + }, + }; +} + +function normalizePerplexityResponse( + data: any, + _query: string, + _searchType: string +): { results: SearchResult[]; totalResults: number | null } { + const now = new Date().toISOString(); + const items = data.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + + const results = items.map((item: any, idx: number) => + makeResult( + "perplexity-search", + { + title: item.title, + url: item.url, + snippet: item.snippet, + published_at: item.date || item.last_updated, + }, + idx, + now + ) + ); + return { results, totalResults: results.length }; +} + +function normalizeExaResponse( + data: any, + _query: string, + _searchType: string +): { results: SearchResult[]; totalResults: number | null } { + const now = new Date().toISOString(); + const items = data.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + + const results = items.map((item: any, idx: number) => + makeResult( + "exa-search", + { + title: item.title, + url: item.url, + snippet: item.highlights?.[0] || item.text?.slice(0, 300) || "", + score: item.score, + published_at: item.publishedDate, + favicon_url: item.favicon, + author: item.author, + image_url: item.image, + full_text: item.text, + text_format: "text", + }, + idx, + now + ) + ); + return { results, totalResults: results.length }; +} + +function normalizeTavilyResponse( + data: any, + _query: string, + _searchType: string +): { results: SearchResult[]; totalResults: number | null } { + const now = new Date().toISOString(); + const items = data.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + + const results = items.map((item: any, idx: number) => + makeResult( + "tavily-search", + { + title: item.title, + url: item.url, + snippet: item.content || "", + score: item.score, + published_at: item.published_date, + full_text: item.raw_content, + text_format: "text", + }, + idx, + now + ) + ); + return { results, totalResults: results.length }; +} + +function normalizeResponse( + providerId: string, + data: any, + query: string, + searchType: string +): { results: SearchResult[]; totalResults: number | null } { + if (providerId === "serper-search") return normalizeSerperResponse(data, query, searchType); + if (providerId === "brave-search") return normalizeBraveResponse(data, query, searchType); + if (providerId === "perplexity-search") + return normalizePerplexityResponse(data, query, searchType); + if (providerId === "exa-search") return normalizeExaResponse(data, query, searchType); + if (providerId === "tavily-search") return normalizeTavilyResponse(data, query, searchType); + return { results: [], totalResults: null }; +} + +// ── Main Handler ──────────────────────────────────────────────────────── + +export async function handleSearch(options: SearchHandlerOptions): Promise { + const { + query, + provider: providerId, + maxResults, + searchType, + country, + language, + domainFilter, + credentials, + alternateProvider, + alternateCredentials, + log, + } = options; + const startTime = Date.now(); + + // 1. Sanitize input + const { clean: cleanQuery, error: sanitizeError } = sanitizeQuery(query); + if (sanitizeError) { + return { success: false, status: 400, error: sanitizeError }; + } + + // 2. Use resolved provider from route (no re-resolution) + const primaryConfig = getSearchProvider(providerId); + if (!primaryConfig) { + return { + success: false, + status: 400, + error: `Unknown search provider: ${providerId}`, + }; + } + + // 3. Get alternate config for failover (pre-resolved by route) + const alternateConfig = alternateProvider ? getSearchProvider(alternateProvider) : null; + + const requestParams = { + query: cleanQuery, + searchType, + maxResults, + country, + language, + domainFilter, + }; + + // 4. Try primary provider + const result = await tryProvider(primaryConfig, requestParams, credentials, startTime, log); + + if (result.success) return result; + + // 5. Failover to alternate (only for retriable errors and auto-select mode) + if ( + alternateConfig && + alternateCredentials && + !NON_RETRIABLE.has(result.status || 0) && + Date.now() - startTime < GLOBAL_TIMEOUT_MS + ) { + if (log) { + log.warn( + "SEARCH", + `${primaryConfig.id} failed (${result.status}), trying ${alternateConfig.id}` + ); + } + + const fallbackResult = await tryProvider( + alternateConfig, + requestParams, + alternateCredentials, + startTime, + log + ); + + if (fallbackResult.success) return fallbackResult; + } + + return result; +} + +async function tryProvider( + config: SearchProviderConfig, + params: Omit, + credentials: Record, + globalStartTime: number, + log?: any +): Promise { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + + if (!token) { + return { + success: false, + status: 401, + error: `No credentials for search provider: ${config.id}`, + }; + } + + const { query, searchType, maxResults } = params; + const { url, init } = buildRequest(config, { ...params, token }); + + // Timeout: min of provider timeout and remaining global timeout + const remainingGlobal = GLOBAL_TIMEOUT_MS - (Date.now() - globalStartTime); + const timeout = Math.min(config.timeoutMs, Math.max(remainingGlobal, 1000)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + + if (log) { + log.info("SEARCH", `${config.id} | query: "${query.slice(0, 80)}" | type: ${searchType}`); + } + + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + clearTimeout(timer); + + if (!response.ok) { + const errorText = await response.text(); + if (log) { + log.error("SEARCH", `${config.id} error ${response.status}: ${errorText.slice(0, 200)}`); + } + + saveCallLog({ + method: config.method, + path: "/v1/search", + status: response.status, + model: config.id, + provider: config.id, + duration: Date.now() - startTime, + requestType: "search", + error: errorText.slice(0, 500), + requestBody: { + query: query.slice(0, 200), + search_type: searchType, + max_results: maxResults, + }, + }).catch(() => { /* non-critical — logging must not block search response */ }); + + return { + success: false, + status: response.status, + error: `Search provider ${config.id} returned ${response.status}`, + }; + } + + const data = await response.json(); + const { results, totalResults } = normalizeResponse(config.id, data, query, searchType); + const duration = Date.now() - startTime; + + saveCallLog({ + method: config.method, + path: "/v1/search", + status: 200, + model: config.id, + provider: config.id, + duration, + requestType: "search", + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults }, + responseBody: { results_count: results.length, cached: false }, + }).catch(() => { /* non-critical — logging must not block search response */ }); + + return { + success: true, + data: { + provider: config.id, + query, + results, + answer: null, + usage: { queries_used: 1, search_cost_usd: config.costPerQuery }, + metrics: { + response_time_ms: duration, + upstream_latency_ms: duration, + total_results_available: totalResults, + }, + errors: [], + }, + }; + } catch (err: any) { + clearTimeout(timer); + + const isTimeout = err.name === "AbortError"; + if (log) { + log.error("SEARCH", `${config.id} ${isTimeout ? "timeout" : "fetch error"}: ${err.message}`); + } + + saveCallLog({ + method: config.method, + path: "/v1/search", + status: isTimeout ? 504 : 502, + model: config.id, + provider: config.id, + duration: Date.now() - startTime, + requestType: "search", + error: err.message, + requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults }, + }).catch(() => { /* non-critical — logging must not block search response */ }); + + return { + success: false, + status: isTimeout ? 504 : 502, + error: `Search provider ${isTimeout ? "timeout" : "error"}: ${err.message}`, + }; + } +} diff --git a/open-sse/services/searchCache.ts b/open-sse/services/searchCache.ts new file mode 100644 index 0000000000..22d304be2f --- /dev/null +++ b/open-sse/services/searchCache.ts @@ -0,0 +1,142 @@ +/** + * Search Cache — in-memory TTL cache with request coalescing + * + * Bounded at MAX_CACHE_ENTRIES to prevent OOM. + * Request coalescing deduplicates concurrent identical queries + * to prevent cache stampede (critical for agentic tools). + */ + +import { createHash } from "crypto"; + +const MAX_CACHE_ENTRIES = 5000; +const DEFAULT_TTL_MS = parseInt(process.env.SEARCH_CACHE_TTL_MS || String(5 * 60 * 1000), 10); + +interface CacheEntry { + data: T; + expiresAt: number; +} + +const cache = new Map>(); +const inflight = new Map>(); + +let hits = 0; +let misses = 0; + +/** + * Normalize a query for cache key computation. + * NFKC normalization, lowercase, trim, collapse whitespace. + */ +function normalizeQuery(query: string): string { + return query.normalize("NFKC").toLowerCase().trim().replace(/\s+/g, " "); +} + +/** + * Compute a deterministic cache key from search parameters. + */ +export function computeCacheKey( + query: string, + provider: string, + searchType: string, + maxResults: number, + country?: string, + language?: string, + filters?: unknown +): string { + const normalized = normalizeQuery(query); + const payload = JSON.stringify({ + q: normalized, + p: provider, + t: searchType, + n: maxResults, + c: country || null, + l: language || null, + f: filters || null, + }); + return createHash("sha256").update(payload).digest("hex"); +} + +/** + * Evict expired entries and enforce size bound. + * Called lazily on writes. O(n) worst case but amortized O(1). + */ +function evictIfNeeded(): void { + const now = Date.now(); + + // Remove expired entries first + for (const [key, entry] of cache) { + if (entry.expiresAt <= now) { + cache.delete(key); + } + } + + // FIFO eviction if still over limit + while (cache.size >= MAX_CACHE_ENTRIES) { + const firstKey = cache.keys().next().value; + if (firstKey !== undefined) { + cache.delete(firstKey); + } else { + break; + } + } +} + +/** + * Get or coalesce: return cached data, join an inflight request, + * or execute the fetch function and cache the result. + * + * @param key - Cache key from computeCacheKey() + * @param ttlMs - TTL in milliseconds (0 to bypass cache) + * @param fetchFn - Function to execute on cache miss + * @returns The cached or freshly fetched data + */ +export async function getOrCoalesce( + key: string, + ttlMs: number, + fetchFn: () => Promise +): Promise<{ data: T; cached: boolean }> { + // 1. Check cache + const cached = cache.get(key) as CacheEntry | undefined; + if (cached && cached.expiresAt > Date.now()) { + hits++; + return { data: cached.data, cached: true }; + } + + // 2. Join inflight request if one exists (request coalescing) + const existing = inflight.get(key) as Promise | undefined; + if (existing) { + hits++; + const data = await existing; + return { data, cached: true }; + } + + // 3. Cache miss — execute fetch + misses++; + const promise = fetchFn(); + inflight.set(key, promise); + + try { + const data = await promise; + + // Store in cache + if (ttlMs > 0) { + evictIfNeeded(); + cache.set(key, { data, expiresAt: Date.now() + ttlMs }); + } + + return { data, cached: false }; + } finally { + inflight.delete(key); + } +} + +/** + * Get cache statistics for monitoring. + */ +export function getCacheStats(): { size: number; hits: number; misses: number } { + return { size: cache.size, hits, misses }; +} + +/** + * Default TTL for search cache entries. + */ +export const SEARCH_CACHE_DEFAULT_TTL_MS = DEFAULT_TTL_MS; diff --git a/public/providers/brave-search.png b/public/providers/brave-search.png new file mode 100644 index 0000000000000000000000000000000000000000..1edb009c53e489e398682e2ed31057ada8e91cbc GIT binary patch literal 3304 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 mx*_=uLGX@3mXi=~&*OgtJ+VZ$#JTJM0000 \ No newline at end of file diff --git a/public/providers/exa-search.png b/public/providers/exa-search.png new file mode 100644 index 0000000000000000000000000000000000000000..9300e7997fa960905c66fc2b8133470236c73b51 GIT binary patch literal 6768 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 Q|5bZ5AL*%9DI-Gv57JAV-T(jq literal 0 HcmV?d00001 diff --git a/public/providers/exa-search.svg b/public/providers/exa-search.svg new file mode 100644 index 0000000000..6c5813283a --- /dev/null +++ b/public/providers/exa-search.svg @@ -0,0 +1,4 @@ + + + exa + diff --git a/public/providers/exa.svg b/public/providers/exa.svg new file mode 100644 index 0000000000..6c5813283a --- /dev/null +++ b/public/providers/exa.svg @@ -0,0 +1,4 @@ + + + exa + diff --git a/public/providers/perplexity-search.png b/public/providers/perplexity-search.png new file mode 100644 index 0000000000000000000000000000000000000000..302eae639e9f0708fc1cf0d5ecae84b6a881a846 GIT binary patch literal 7175 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 literal 0 HcmV?d00001 diff --git a/public/providers/perplexity.png b/public/providers/perplexity.png index 4e9d56fab25c1b52c352fa22d3361096f1dbbde9..302eae639e9f0708fc1cf0d5ecae84b6a881a846 100644 GIT binary patch literal 7175 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 literal 2108 zcmd5;`#;nBAAirrY$Zb_VvXb$!bvBW@v)pT+ZVYWm(Won6~dW9W=3>X>9{MA=3eeG z=TM?zwnT0@y0C`XhD^DB=kfg~zCS!)&*$U$d_5ko-(Ig*nzQ3EIkYMo03b)Sw{wv& zbrZ5OlKRs6WVeJ+;r3pU0Blj(1PtWl?E(NPAlliw#pKQA`kZt7O|6ri^r=OQ=3wFK zz5S9%QPDrX4_PEbPsNx8yD>H7ukn(4>gwp-t3GIt5KRX};lqmxdKI#oxW+!hb!Yv9 z7w?l2p#yz=HGx5~3eQ@`#sq>qLEOeftX1~Z(wf*NGRP5s0QRcmqXwB{r|qtAPrt3$ zqEq_BNy6Bty9ezeVlnfa7`=gKj$t>ulx}=0U^P=dys-7<#FEP{BFG-NGc<8TX-SrW z%g(rlEPMtkhyD2MQPIS0fi@$JU1qw{n%5CvS4?NPsWJbrbVHCMHU zB4nWDc^k@>hero{+jJOHHp=!~5B72~F5f}kwD|J3aq;c_CuUWtZQ#a}4J8^*<{IYHSozM_~^+00V&uZ946(m63b?Vj47(g5(}? zg}0qXK#qjhS_83Um-5*(=oAtZdiPx2BMUXpz(BhA2msc`G)w&`5MqM>pWu6&iU`R6 zGaH{Jn(q@Xj+TEo_SiGC`W-u=-o>V#7rMs#$X-6h8+x_>5q4>DN9f`flE>5&(qGeE z{hBi;OWcu+))-=!&zb5&yqdiUJjy?7$K@ZG+muAW&APr|8~-l#3j0~)n83VJi!~2T zjF!)r{v>v`_Eok=fkux&Qn`oEpVJk2QZs%Qa*$``#u}ry*O06NPSgu0RMt3rr^=Nn zD^TL0{sTV=vPL4mMD>sqfPFZUr(b)CWClvO=Z8b^{5mhGw@-AkUybv5(pMwY(i z0Hu~Ku;2<#MA@#eCT3)ky(M@RO{zW;EcMiA-^=R9^g zw5n#3`sjR%5*i5mNu+HR3J51&J~o#2ls)Lj zXxvP9-S@zvSD0Rn_oUnStkJy%#3Av33b;n$-)zbz?F5o3=#@B$qo;o0AGIzkjWfIx2 zPnxQ8J=4zW`j4Q5`_caIvD8M7^@q9MK6|A^c5`>>aAQHYOMF$K$(JVX@=!x{2(0a! z<qG zH0>tW;ZS-2=(!sx&^@;@WdJH?XLB0XcUoX?1$~5Qph;zGO0VY8i5*@`eMF$@?M;AL z{T&cN{wlL9OEsUJ88H|U8=r?A_Dk>*R#*;K&UQJ`1>8)2Wxx6E1XQa^M+;X*69iZv zYK`{Y8!4)-U^;x;_+EZxZblflah<}|GWL^Rj!_&MM*uDG)KS9%6v6{gI0M)a93uup z2*pE*F!sG7)eK#6_%flo~Ak9>%ov)+KTob<^&G+n$!8Y9gRp z1O{3TDDj;TUpy?J_65J7_0@J?29AJhgtqGk*DR4uY*#9rWcx}Np;>YBHIkV$EZr4j zY8ys@hYKr`lbRlCkKUu{nqg(nnr2hroMCCS0(cH9bbi~?n!qdM7j2Ma<&N(!OKDpDc_^o0FtGGERp$6l`JK%3N|M3Nq z8GLP$v&+cMlJM|5cy~&#zxH_Yc~gi-ONb zidf`yHOcWf;C26wXSlqw8`bs5{A~+Cgf1WDZ~E3(`xN?eCTb)}h(lt zNkKPv*LuC|V75GW`)X;F(SKCC|M)_^iM6>fmZOd>>&`E@!2ce@oU>9GUW5B;E(G&d SWi%x303iP6XjerDp#2vbqQYMQ diff --git a/public/providers/serper-search.png b/public/providers/serper-search.png new file mode 100644 index 0000000000000000000000000000000000000000..e8778e796b09eee8ea60b7aa880af28993633bd6 GIT binary patch literal 2798 zcmVPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NMSV=@dRA>dgS$&LD)fKi4fxS&Z3-$PyE`+xz|MQ8zw?;)=FZ!BJF~EGlihdDx#ygB&b{ZJ zbKV_N<1n_pnY=C?jqrRmWXs1Y z@57%0eMf+3^SY|g2_K%kzij0)NwG&SxAZ z!@Rq;a?(3K^Deod15G`NyGYRskjS^{DI(fQOgm%Mijizo?2bz0QYp`J`kLq`p1Coht-XmUCR05Oi9@i| zBWx-~567!Rzwppq)YpNQ-qb_L5Zhf&2OxUGG|UG#%!|J5kjCTL);%!WBx;Aj3qAS} zm9)ms354s&Az=gKw~m}q4SoIP%f{PydC$6<=M9(wkU^_;1FG^aUQk!LIQhY@I=E`X`r4w(%IRuB7p@ zI<-}iHpN`$S*a*nfeuOMIz7jMilNkcY)MY4L;!BSQY)_;7O#o40+(ITM0fYm=naYaP0J~*3;DsjcXQz6=Tl!{MPJ7*EzsLmG=TOQ zkggIFS>u%&G!!>F3jCh3@+IlPoq3Eu4ViX?(i5oSYf!0wh`-;0ruR3~tpM+BJ34fu zpMHmKvh@sLY51HGGD?km1PRJN*dZ;kJJ24y7^?~w+}@VnsX2{jV6~Gm7S971Fjs#tSc2pMz;@Ex`X~WK%|f3VzHWt?mcE zEVMJoveYs|J_8*}=Gp1NM?=e;NoMPe0~dNedTaDF8-bDV~jLU?Vf2xdyX zYb9G|(nSgYYv){>Y~0^}yD>4a2YQxi?5LW&1o#p(f{F}mJoXw{>jdh-enr-Aq66VS zJg}V3YBB{uBalv@q0{*yb|KR#V}+#})>ejAU=tsJtw##TPiNw9!ljP>XzJbO?!;KD z_Xza{wm)aZ$p}dEHKrjMqOLmpLcAtiDLEbI3I&Irg_2kC=cG%o&g+p=cQ^N@p72ii z;g%Wno~;BzNOCY=!DM&eot@YsPs2M1&!k6_5gpjlVGh8|5A0xE7 z;??0lxio)3Y}3aXv=aO;P~1#}!Y~u~UY+Yw;n>2f2cTo0{Gk&6sI@n>3Lz)o+c+zd zfz|i%EbM^6vk<1)LFyd?`0*?S5EXw-*nBWK6}JjsmM^&+9it!;Znf3ncASNuxooT! zZ(=TZ9?4QQu8aVz-a>xh13D27Eth^pr{#m?Iz*MIf1r}@n|yZg)nW|D)kDWLyfk`1 z>-V(@2efT6?6$`t!2v#C3k5M#tLjLHZ8Vx zC%=kFdf0g*w)@Wx?Dw=Nx8=v0ACMj@6L< zlwm5D>psAbXYjm_x~kCLrrzX6?1a;l$=O+DFt36m$p7>oB!!d2T!9_B8FSNt>jQ_4 z9*tFnj_O1&OhNS+f&v8ok6ZZ{PB5ud9$i`hj=@;oV7 zbv;!IQyRuUbX}l#D^_(k?Q1XKY^NfPM9Gfj;VgY0RXzk@f(E z>^?BN*E5bF4C^;Bx4t^`M~9a0S9h|<$dtkjxf*0k0FmCGPU_~Ug7F+!!(1_43 z!$_eRZXWj^x^+XFx>M6|b6pQd)`0HxPCCm)!1&QZ@y~J1xn=Ewuzr15P*>4A;MIid zl&kD3Zc^wH?d!?0>OvksMq{;WLaF)dM{b#1^wp$p%UEBk%et_%-Y{f2@*Ua-`>OJD zcwU1)C)5&vKf&L>(V85?Y9CB1?!43=$)QjEA69|N0GdCwl>h($07*qoM6N<$g8Ohl A#Q*>R literal 0 HcmV?d00001 diff --git a/public/providers/serper.png b/public/providers/serper.png new file mode 100644 index 0000000000000000000000000000000000000000..e8778e796b09eee8ea60b7aa880af28993633bd6 GIT binary patch literal 2798 zcmVPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NMSV=@dRA>dgS$&LD)fKi4fxS&Z3-$PyE`+xz|MQ8zw?;)=FZ!BJF~EGlihdDx#ygB&b{ZJ zbKV_N<1n_pnY=C?jqrRmWXs1Y z@57%0eMf+3^SY|g2_K%kzij0)NwG&SxAZ z!@Rq;a?(3K^Deod15G`NyGYRskjS^{DI(fQOgm%Mijizo?2bz0QYp`J`kLq`p1Coht-XmUCR05Oi9@i| zBWx-~567!Rzwppq)YpNQ-qb_L5Zhf&2OxUGG|UG#%!|J5kjCTL);%!WBx;Aj3qAS} zm9)ms354s&Az=gKw~m}q4SoIP%f{PydC$6<=M9(wkU^_;1FG^aUQk!LIQhY@I=E`X`r4w(%IRuB7p@ zI<-}iHpN`$S*a*nfeuOMIz7jMilNkcY)MY4L;!BSQY)_;7O#o40+(ITM0fYm=naYaP0J~*3;DsjcXQz6=Tl!{MPJ7*EzsLmG=TOQ zkggIFS>u%&G!!>F3jCh3@+IlPoq3Eu4ViX?(i5oSYf!0wh`-;0ruR3~tpM+BJ34fu zpMHmKvh@sLY51HGGD?km1PRJN*dZ;kJJ24y7^?~w+}@VnsX2{jV6~Gm7S971Fjs#tSc2pMz;@Ex`X~WK%|f3VzHWt?mcE zEVMJoveYs|J_8*}=Gp1NM?=e;NoMPe0~dNedTaDF8-bDV~jLU?Vf2xdyX zYb9G|(nSgYYv){>Y~0^}yD>4a2YQxi?5LW&1o#p(f{F}mJoXw{>jdh-enr-Aq66VS zJg}V3YBB{uBalv@q0{*yb|KR#V}+#})>ejAU=tsJtw##TPiNw9!ljP>XzJbO?!;KD z_Xza{wm)aZ$p}dEHKrjMqOLmpLcAtiDLEbI3I&Irg_2kC=cG%o&g+p=cQ^N@p72ii z;g%Wno~;BzNOCY=!DM&eot@YsPs2M1&!k6_5gpjlVGh8|5A0xE7 z;??0lxio)3Y}3aXv=aO;P~1#}!Y~u~UY+Yw;n>2f2cTo0{Gk&6sI@n>3Lz)o+c+zd zfz|i%EbM^6vk<1)LFyd?`0*?S5EXw-*nBWK6}JjsmM^&+9it!;Znf3ncASNuxooT! zZ(=TZ9?4QQu8aVz-a>xh13D27Eth^pr{#m?Iz*MIf1r}@n|yZg)nW|D)kDWLyfk`1 z>-V(@2efT6?6$`t!2v#C3k5M#tLjLHZ8Vx zC%=kFdf0g*w)@Wx?Dw=Nx8=v0ACMj@6L< zlwm5D>psAbXYjm_x~kCLrrzX6?1a;l$=O+DFt36m$p7>oB!!d2T!9_B8FSNt>jQ_4 z9*tFnj_O1&OhNS+f&v8ok6ZZ{PB5ud9$i`hj=@;oV7 zbv;!IQyRuUbX}l#D^_(k?Q1XKY^NfPM9Gfj;VgY0RXzk@f(E z>^?BN*E5bF4C^;Bx4t^`M~9a0S9h|<$dtkjxf*0k0FmCGPU_~Ug7F+!!(1_43 z!$_eRZXWj^x^+XFx>M6|b6pQd)`0HxPCCm)!1&QZ@y~J1xn=Ewuzr15P*>4A;MIid zl&kD3Zc^wH?d!?0>OvksMq{;WLaF)dM{b#1^wp$p%UEBk%et_%-Y{f2@*Ua-`>OJD zcwU1)C)5&vKf&L>(V85?Y9CB1?!43=$)QjEA69|N0GdCwl>h($07*qoM6N<$g8Ohl A#Q*>R literal 0 HcmV?d00001 diff --git a/public/providers/tavily-search.png b/public/providers/tavily-search.png new file mode 100644 index 0000000000000000000000000000000000000000..153585db0139b481ea88dd4f26fd68102fc04fb8 GIT binary patch literal 1306 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 Q#{d8T07*qoM6N<$g3(null); const [a2aStatus, setA2aStatus] = useState(null); + const [searchProviders, setSearchProviders] = useState([]); const { copied, copy } = useCopyToClipboard(); + const fetchSearchProviders = async () => { + try { + const res = await fetch("/v1/search"); + if (res.ok) { + const data = await res.json(); + setSearchProviders(data.data || []); + } + } catch { + // Search endpoint may not be available + } + }; + useEffect(() => { - Promise.allSettled([loadCloudSettings(), fetchModels(), fetchProtocolStatus()]).finally(() => { + Promise.allSettled([ + loadCloudSettings(), + fetchModels(), + fetchProtocolStatus(), + fetchSearchProviders(), + ]).finally(() => { setLoading(false); }); }, []); @@ -575,6 +593,47 @@ export default function APIPageClient({ machineId }) { + {/* Search & Discovery */} + {searchProviders.length > 0 && ( +
+
+ + travel_explore + +

+ {t("categorySearch") || "Search & Discovery"} +

+
+
+
+ ({ + id: p.id, + name: p.name, + owned_by: p.id, + type: "search", + }))} + expanded={expandedEndpoint === "search"} + onToggle={() => + setExpandedEndpoint(expandedEndpoint === "search" ? null : "search") + } + copy={copy} + copied={copied} + baseUrl={currentEndpoint} + /> +
+
+ )} + {/* Utility & Management */}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index eaac32caae..ff770ed039 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -101,6 +101,7 @@ export default function ProviderDetailPage() { const isOpenAICompatible = isOpenAICompatibleProvider(providerId); const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId); const isCompatible = isOpenAICompatible || isAnthropicCompatible; + const isSearchProvider = providerId.endsWith("-search"); const providerStorageAlias = isCompatible ? providerId : providerAlias; const providerDisplayAlias = isCompatible ? providerNode?.prefix || providerId : providerAlias; @@ -1060,21 +1061,43 @@ export default function ProviderDetailPage() { )} - {/* Models */} - -

{t("availableModels")}

- {renderModelsSection()} + {/* Models — hidden for search providers (they don't have models) */} + {!isSearchProvider && ( + +

{t("availableModels")}

+ {renderModelsSection()} - {/* Custom Models — available for ALL providers */} - {!isCompatible && ( - - )} -
+ {/* Custom Models — available for non-compatible, non-search providers */} + {!isCompatible && ( + + )} +
+ )} + + {/* Search provider info */} + {isSearchProvider && ( + +

{t("searchProvider") || "Search Provider"}

+

+ {t("searchProviderDesc") || + "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."} +

+ {providerId === "perplexity-search" && ( +
+ link +

+ Uses the same API key as Perplexity (chat provider). If you already + have Perplexity configured, no additional setup is needed. +

+
+ )} +
+ )} {/* Modals */} {providerId === "kiro" ? ( diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts new file mode 100644 index 0000000000..dc119b981c --- /dev/null +++ b/src/app/api/v1/search/route.ts @@ -0,0 +1,268 @@ +import { CORS_ORIGIN } from "@/shared/utils/cors"; +import { handleSearch } from "@omniroute/open-sse/handlers/search.ts"; +import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { + getAllSearchProviders, + getSearchProvider, + selectProvider, + SEARCH_PROVIDERS, + SEARCH_CREDENTIAL_FALLBACKS, +} from "@omniroute/open-sse/config/searchRegistry.ts"; +import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import * as log from "@/sse/utils/logger"; +import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; +import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; +import { v1SearchSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { recordCost } from "@/domain/costRules"; +import { + computeCacheKey, + getOrCoalesce, + SEARCH_CACHE_DEFAULT_TTL_MS, +} from "@omniroute/open-sse/services/searchCache.ts"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": CORS_ORIGIN, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", +}; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { headers: CORS_HEADERS }); +} + +/** + * GET /v1/search — list available search providers + */ +export async function GET() { + const providers = getAllSearchProviders(); + const timestamp = Math.floor(Date.now() / 1000); + + const data = providers.map((p) => ({ + id: p.id, + object: "search_provider", + created: timestamp, + name: p.name, + search_types: p.searchTypes, + })); + + return new Response(JSON.stringify({ object: "list", data }), { + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); +} + +// Helper: resolve credentials with fallback (e.g., perplexity-search → perplexity) +async function resolveSearchCredentials(providerId: string) { + const creds = await getProviderCredentials(providerId).catch(() => null); + if (creds) return creds; + const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[providerId]; + if (fallbackId) return getProviderCredentials(fallbackId).catch(() => null); + return null; +} + +// Helper: build domain filter array from filters object +function buildDomainFilter(filters?: { + include_domains?: string[]; + exclude_domains?: string[]; +}): string[] | undefined { + if (!filters) return undefined; + const parts: string[] = []; + if (filters.include_domains?.length) parts.push(...filters.include_domains); + if (filters.exclude_domains?.length) parts.push(...filters.exclude_domains.map((d) => `-${d}`)); + return parts.length > 0 ? parts : undefined; +} + +/** + * POST /v1/search — execute a web search + */ +export async function POST(request: Request) { + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + log.warn("SEARCH", "Invalid JSON body"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + const validation = validateBody(v1SearchSchema, rawBody); + if (isValidationFailure(validation)) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message); + } + const body = validation.data; + + // Optional API key validation + if (process.env.REQUIRE_API_KEY === "true") { + const apiKey = extractApiKey(request); + if (!apiKey) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); + } + const valid = await isValidApiKey(apiKey); + if (!valid) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + } + + // Enforce API key policies — use "search" as model identifier for consistent policy config + const policy = await enforceApiKeyPolicy(request, "search"); + if (policy.rejection) return policy.rejection; + + // Resolve provider and credentials + let providerConfig = selectProvider(body.provider); + if (!providerConfig) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + body.provider ? `Unknown search provider: ${body.provider}` : "No search providers available" + ); + } + + let credentials: Record | null = null; + let alternateProviderId: string | undefined; + let alternateCredentials: Record | null = null; + + if (body.provider) { + // Explicit provider — single credential lookup (with fallback) + credentials = await resolveSearchCredentials(providerConfig.id); + if (!credentials) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials configured for search provider: ${providerConfig.id}. Add an API key for "${providerConfig.id}" in the dashboard.` + ); + } + } else { + // Auto-select — try the resolved provider first, then iterate others by cost + credentials = await resolveSearchCredentials(providerConfig.id); + + if (!credentials) { + // Sort by cost to find cheapest with credentials + const sortedIds = Object.values(SEARCH_PROVIDERS) + .sort((a, b) => a.costPerQuery - b.costPerQuery) + .map((p) => p.id); + + for (const pid of sortedIds) { + if (pid === providerConfig.id) continue; + const altConfig = getSearchProvider(pid); + const altCreds = await resolveSearchCredentials(pid); + if (altConfig && altCreds) { + providerConfig = altConfig; + credentials = altCreds; + break; + } + } + } + + if (!credentials) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials configured for any search provider. Add an API key for a search provider (${Object.keys(SEARCH_PROVIDERS).join(", ")}) in the dashboard.` + ); + } + + // Find alternate for failover — must bind credentials to the matched provider + const otherIds = Object.values(SEARCH_PROVIDERS) + .sort((a, b) => a.costPerQuery - b.costPerQuery) + .map((p) => p.id) + .filter((id) => id !== providerConfig.id); + + for (const pid of otherIds) { + const creds = await resolveSearchCredentials(pid); + if (creds) { + alternateProviderId = pid; + alternateCredentials = creds; + break; + } + } + } + + // Clamp max_results to provider limit + const clampedMaxResults = Math.min(body.max_results, providerConfig.maxMaxResults); + + // Cache key — includes all fields that affect results + const cacheKey = computeCacheKey( + body.query, + providerConfig.id, + body.search_type, + clampedMaxResults, + body.country, + body.language, + { filters: body.filters, offset: body.offset, time_range: body.time_range } + ); + + const ttl = providerConfig.cacheTTLMs || SEARCH_CACHE_DEFAULT_TTL_MS; + + try { + const { data: searchResult, cached } = await getOrCoalesce(cacheKey, ttl, async () => { + const result = await handleSearch({ + query: body.query, + provider: providerConfig.id, + maxResults: clampedMaxResults, + searchType: body.search_type, + country: body.country, + language: body.language, + timeRange: body.time_range, + offset: body.offset, + domainFilter: buildDomainFilter(body.filters), + contentOptions: body.content, + strictFilters: body.strict_filters, + providerOptions: body.provider_options, + credentials, + alternateProvider: alternateProviderId, + alternateCredentials, + log, + }); + + if (!result.success) { + throw new SearchError(result.error || "Search failed", result.status || 502); + } + + return result.data!; + }); + + // Record cost for budget tracking (skip cache hits — no provider cost) + if (!cached && policy.apiKeyInfo?.id && searchResult.usage?.search_cost_usd > 0) { + try { + recordCost(policy.apiKeyInfo.id, searchResult.usage.search_cost_usd); + } catch (e: any) { + log.warn("SEARCH", `Cost recording failed: ${e?.message}`); + } + } + + const response = { + id: `search-${crypto.randomUUID()}`, + ...searchResult, + cached, + usage: cached ? { queries_used: 0, search_cost_usd: 0 } : searchResult.usage, + }; + + return new Response(JSON.stringify(response), { + status: 200, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } catch (err: any) { + if (err instanceof SearchError) { + const errorPayload = toJsonErrorPayload(err.message, "Search provider error"); + return new Response(JSON.stringify(errorPayload), { + status: err.statusCode, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } + + log.error("SEARCH", `Unexpected error: ${err.message}`); + const errorPayload = toJsonErrorPayload(err.message, "Internal search error"); + return new Response(JSON.stringify(errorPayload), { + status: 500, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } +} + +class SearchError extends Error { + statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c93a0827a6..48b8cdd0fd 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -818,7 +818,12 @@ "settingsApi": "Settings API", "categoryCore": "Core APIs", "categoryMedia": "Media & Multi-Modal", + "categorySearch": "Search & Discovery", "categoryUtility": "Utility & Management", + "webSearch": "Web Search", + "webSearchDesc": "Unified web search across multiple providers with automatic failover and caching", + "searchProvider": "Search Provider", + "searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.", "enableCloudTitle": "Enable Cloud Proxy", "whatYouGet": "What you will get", "cloudBenefitAccess": "Access your API from anywhere in the world", diff --git a/src/lib/db/migrations/007_search_request_type.sql b/src/lib/db/migrations/007_search_request_type.sql new file mode 100644 index 0000000000..3917771df8 --- /dev/null +++ b/src/lib/db/migrations/007_search_request_type.sql @@ -0,0 +1,4 @@ +-- Add request_type column to call_logs for non-chat request tracking (search, embed, rerank). +-- Backward-compatible: DEFAULT NULL means existing rows are unaffected. +ALTER TABLE call_logs ADD COLUMN request_type TEXT DEFAULT NULL; +CREATE INDEX IF NOT EXISTS idx_call_logs_request_type ON call_logs(request_type); diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 89633c24aa..083f41c078 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -440,6 +440,69 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat } } +// ── Search provider validators (factored) ── + +async function validateSearchProvider( + url: string, + init: RequestInit +): Promise<{ valid: boolean; error: string | null }> { + try { + const response = await fetch(url, init); + if (response.ok) return { valid: true, error: null }; + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + return { valid: false, error: `Validation failed: ${response.status}` }; + } catch (error: any) { + return { valid: false, error: error.message || "Validation failed" }; + } +} + +const SEARCH_VALIDATOR_CONFIGS: Record< + string, + (apiKey: string) => { url: string; init: RequestInit } +> = { + "serper-search": (apiKey) => ({ + url: "https://google.serper.dev/search", + init: { + method: "POST", + headers: { "Content-Type": "application/json", "X-API-Key": apiKey }, + body: JSON.stringify({ q: "test", num: 1 }), + }, + }), + "brave-search": (apiKey) => ({ + url: "https://api.search.brave.com/res/v1/web/search?q=test&count=1", + init: { + method: "GET", + headers: { Accept: "application/json", "X-Subscription-Token": apiKey }, + }, + }), + "perplexity-search": (apiKey) => ({ + url: "https://api.perplexity.ai/search", + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ query: "test", max_results: 1 }), + }, + }), + "exa-search": (apiKey) => ({ + url: "https://api.exa.ai/search", + init: { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": apiKey }, + body: JSON.stringify({ query: "test", numResults: 1 }), + }, + }), + "tavily-search": (apiKey) => ({ + url: "https://api.tavily.com/search", + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ query: "test", max_results: 1 }), + }, + }), +}; + export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { if (!provider || !apiKey) { return { valid: false, error: "Provider and API key required", unsupported: false }; @@ -468,6 +531,16 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi nanobanana: validateNanoBananaProvider, elevenlabs: validateElevenLabsProvider, inworld: validateInworldProvider, + // Search providers — use factored validator + ...Object.fromEntries( + Object.entries(SEARCH_VALIDATOR_CONFIGS).map(([id, configFn]) => [ + id, + ({ apiKey }: any) => { + const { url, init } = configFn(apiKey); + return validateSearchProvider(url, init); + }, + ]) + ), }; if (SPECIALTY_VALIDATORS[provider]) { diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 05d8c677c9..8c6bfc5875 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -186,6 +186,7 @@ export async function saveCallLog(entry: any) { duration: entry.duration || 0, tokensIn: entry.tokens?.prompt_tokens || 0, tokensOut: entry.tokens?.completion_tokens || 0, + requestType: entry.requestType || null, sourceFormat: entry.sourceFormat || null, targetFormat: entry.targetFormat || null, apiKeyId, @@ -201,10 +202,10 @@ export async function saveCallLog(entry: any) { db.prepare( ` INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, - account, connection_id, duration, tokens_in, tokens_out, source_format, target_format, + account, connection_id, duration, tokens_in, tokens_out, request_type, source_format, target_format, api_key_id, api_key_name, combo_name, request_body, response_body, error) VALUES (@id, @timestamp, @method, @path, @status, @model, @provider, - @account, @connectionId, @duration, @tokensIn, @tokensOut, @sourceFormat, @targetFormat, + @account, @connectionId, @duration, @tokensIn, @tokensOut, @requestType, @sourceFormat, @targetFormat, @apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error) ` ).run(logEntry); diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 8da4fa6bf7..5f9d93fbc5 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -390,8 +390,6 @@ export const APIKEY_PROVIDERS = { website: "https://cloud.google.com/vertex-ai", authHint: "Provide Service Account JSON or OAuth access_token", }, - // Z.AI (formerly ZhipuAI) — GLM-5 family with 128k output - // Added 2026-03-17 based on ClawRouter feature analysis zai: { id: "zai", alias: "zai", @@ -402,6 +400,56 @@ export const APIKEY_PROVIDERS = { website: "https://open.bigmodel.cn", apiHint: "API key from https://open.bigmodel.cn/usercenter/apikeys", }, + "perplexity-search": { + id: "perplexity-search", + alias: "pplx-search", + name: "Perplexity Search", + icon: "search", + color: "#20808D", + textIcon: "PS", + website: "https://docs.perplexity.ai/guides/search-quickstart", + authHint: "Same API key as Perplexity (pplx-...)", + }, + "serper-search": { + id: "serper-search", + alias: "serper-search", + name: "Serper Search", + icon: "search", + color: "#4285F4", + textIcon: "SP", + website: "https://serper.dev", + authHint: "API key from serper.dev dashboard", + }, + "brave-search": { + id: "brave-search", + alias: "brave-search", + name: "Brave Search", + icon: "travel_explore", + color: "#FB542B", + textIcon: "BR", + website: "https://brave.com/search/api", + authHint: "Subscription token from Brave Search API dashboard", + }, + "exa-search": { + id: "exa-search", + alias: "exa-search", + name: "Exa Search", + icon: "neurology", + color: "#1E40AF", + textIcon: "EX", + website: "https://exa.ai", + authHint: "API key from dashboard.exa.ai", + }, + "tavily-search": { + id: "tavily-search", + alias: "tavily-search", + name: "Tavily Search", + icon: "manage_search", + color: "#5B4FDB", + textIcon: "TV", + website: "https://tavily.com", + authHint: "API key from app.tavily.com (format: tvly-...)", + }, }; export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index dbe7758381..9dd115e684 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1081,3 +1081,137 @@ export const guideSettingsSaveSchema = z.object({ apiKey: z.string().optional(), model: z.string().trim().min(1, "Model is required"), }); + +// ── Search Schemas ───────────────────────────────────────────────────── +// Unified search request/response schemas. Final contract — all fields optional +// with defaults. New features add implementations, not new fields. +// Multi-query deferred to POST /v1/search/batch (separate PRD). + +export const v1SearchSchema = z + .object({ + // Core + query: z + .string() + .trim() + .min(1, "Query is required") + .max(500, "Query must be 500 characters or fewer"), + provider: z + .enum(["serper-search", "brave-search", "perplexity-search", "exa-search", "tavily-search"]) + .optional(), + max_results: z.coerce.number().int().min(1).max(100).default(5), + search_type: z.enum(["web", "news"]).default("web"), + offset: z.coerce.number().int().min(0).default(0), + + // Locale + country: z.string().max(2).toUpperCase().optional(), + language: z.string().min(2).max(5).optional(), + time_range: z.enum(["any", "day", "week", "month", "year"]).optional(), + + // Content control + content: z + .object({ + snippet: z.boolean().default(true), + full_page: z.boolean().default(false), + format: z.enum(["text", "markdown"]).default("text"), + max_characters: z.coerce.number().int().min(100).max(100000).optional(), + }) + .optional(), + + // Filters + filters: z + .object({ + include_domains: z.array(z.string().max(253)).max(20).optional(), + exclude_domains: z.array(z.string().max(253)).max(20).optional(), + safe_search: z.enum(["off", "moderate", "strict"]).optional(), + }) + .optional(), + + // Answer synthesis (Phase 2 — returns null until implemented) + synthesis: z + .object({ + strategy: z.enum(["none", "auto", "provider", "internal"]).default("none"), + model: z.string().optional(), + max_tokens: z.coerce.number().int().min(1).max(4000).optional(), + }) + .optional(), + + // Provider-specific passthrough + provider_options: z.record(z.string(), z.unknown()).optional(), + + // Strict mode — reject if provider doesn't support a requested filter + strict_filters: z.boolean().default(false), + }) + .catchall(z.unknown()); + +export const searchResultSchema = z.object({ + title: z.string(), + url: z.string(), + display_url: z.string().optional(), + snippet: z.string(), + position: z.number().int().positive(), + score: z.number().min(0).max(1).nullable().optional(), + published_at: z.string().nullable().optional(), + favicon_url: z.string().nullable().optional(), + content: z + .object({ + format: z.enum(["text", "markdown"]).optional(), + text: z.string().optional(), + length: z.number().int().optional(), + }) + .nullable() + .optional(), + metadata: z + .object({ + author: z.string().nullable().optional(), + language: z.string().nullable().optional(), + source_type: z + .enum(["article", "blog", "forum", "video", "academic", "news", "other"]) + .nullable() + .optional(), + image_url: z.string().nullable().optional(), + }) + .nullable() + .optional(), + citation: z.object({ + provider: z.string(), + retrieved_at: z.string(), + rank: z.number().int().positive(), + }), + provider_raw: z.record(z.string(), z.unknown()).nullable().optional(), +}); + +export const v1SearchResponseSchema = z.object({ + id: z.string(), + provider: z.string(), + query: z.string(), + results: z.array(searchResultSchema), + cached: z.boolean(), + answer: z + .object({ + source: z.enum(["none", "provider", "internal"]).optional(), + text: z.string().nullable().optional(), + model: z.string().nullable().optional(), + }) + .nullable() + .optional(), + usage: z.object({ + queries_used: z.number().int().min(0), + search_cost_usd: z.number().min(0), + llm_tokens: z.number().int().min(0).optional(), + }), + metrics: z.object({ + response_time_ms: z.number().int().min(0), + upstream_latency_ms: z.number().int().min(0).optional(), + gateway_latency_ms: z.number().int().min(0).optional(), + total_results_available: z.number().int().nullable(), + }), + errors: z + .array( + z.object({ + provider: z.string(), + code: z.string(), + message: z.string(), + }) + ) + .optional(), +}); diff --git a/tests/unit/search-registry.test.mjs b/tests/unit/search-registry.test.mjs new file mode 100644 index 0000000000..8bcf74245b --- /dev/null +++ b/tests/unit/search-registry.test.mjs @@ -0,0 +1,277 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// ═══════════════════════════════════════════════════════════════ +// Search Registry + Cache Unit Tests +// Tests for searchRegistry, searchCache, and response normalization +// ═══════════════════════════════════════════════════════════════ + +const { SEARCH_PROVIDERS, getSearchProvider, getAllSearchProviders, selectProvider } = + await import("../../open-sse/config/searchRegistry.ts"); + +const { computeCacheKey, getOrCoalesce, getCacheStats, SEARCH_CACHE_DEFAULT_TTL_MS } = + await import("../../open-sse/services/searchCache.ts"); + +// ─── Registry Tests ────────────────────────────────────────── + +test("SEARCH_PROVIDERS has all 5 providers", () => { + assert.ok(SEARCH_PROVIDERS["serper-search"], "serper should exist"); + assert.ok(SEARCH_PROVIDERS["brave-search"], "brave should exist"); + assert.ok(SEARCH_PROVIDERS["perplexity-search"], "perplexity-search should exist"); + assert.ok(SEARCH_PROVIDERS["exa-search"], "exa should exist"); + assert.ok(SEARCH_PROVIDERS["tavily-search"], "tavily should exist"); + assert.equal(Object.keys(SEARCH_PROVIDERS).length, 5); +}); + +test("serper-search config is correct", () => { + const s = SEARCH_PROVIDERS["serper-search"]; + assert.equal(s.id, "serper-search"); + assert.equal(s.method, "POST"); + assert.equal(s.authHeader, "x-api-key"); + assert.equal(s.costPerQuery, 0.001); + assert.equal(s.freeMonthlyQuota, 2500); + assert.deepEqual(s.searchTypes, ["web", "news"]); +}); + +test("brave-search config is correct", () => { + const b = SEARCH_PROVIDERS["brave-search"]; + assert.equal(b.id, "brave-search"); + assert.equal(b.method, "GET"); + assert.equal(b.authHeader, "x-subscription-token"); + assert.equal(b.costPerQuery, 0.005); + assert.equal(b.freeMonthlyQuota, 1000); +}); + +test("perplexity-search config is correct", () => { + const p = SEARCH_PROVIDERS["perplexity-search"]; + assert.equal(p.id, "perplexity-search"); + assert.equal(p.method, "POST"); + assert.equal(p.authHeader, "bearer"); + assert.equal(p.baseUrl, "https://api.perplexity.ai/search"); + assert.equal(p.costPerQuery, 0.005); + assert.equal(p.freeMonthlyQuota, 0); + assert.deepEqual(p.searchTypes, ["web"]); +}); + +test("getSearchProvider returns config for valid ID", () => { + const config = getSearchProvider("serper-search"); + assert.ok(config); + assert.equal(config.id, "serper-search"); +}); + +test("getSearchProvider returns null for unknown ID", () => { + assert.equal(getSearchProvider("unknown"), null); +}); + +test("tavily config is correct", () => { + const t = SEARCH_PROVIDERS["tavily-search"]; + assert.equal(t.id, "tavily-search"); + assert.equal(t.method, "POST"); + assert.equal(t.authHeader, "bearer"); + assert.equal(t.baseUrl, "https://api.tavily.com/search"); + assert.equal(t.costPerQuery, 0.008); + assert.equal(t.freeMonthlyQuota, 1000); + assert.deepEqual(t.searchTypes, ["web", "news"]); +}); + +test("getAllSearchProviders returns flat list", () => { + const all = getAllSearchProviders(); + assert.equal(all.length, 5); + assert.ok(all.some((p) => p.id === "serper-search")); + assert.ok(all.some((p) => p.id === "brave-search")); + assert.ok(all.some((p) => p.id === "perplexity-search")); + assert.ok(all.some((p) => p.id === "exa-search")); + assert.ok(all.some((p) => p.id === "tavily-search")); + // Each entry should have id, name, searchTypes + for (const p of all) { + assert.ok(p.id); + assert.ok(p.name); + assert.ok(Array.isArray(p.searchTypes)); + } +}); + +test("selectProvider with explicit provider returns that provider", () => { + const config = selectProvider("brave-search"); + assert.ok(config); + assert.equal(config.id, "brave-search"); +}); + +test("selectProvider with unknown provider returns null", () => { + assert.equal(selectProvider("unknown"), null); +}); + +test("selectProvider without argument returns cheapest (serper)", () => { + const config = selectProvider(); + assert.ok(config); + assert.equal(config.id, "serper-search"); // $0.001 < $0.005 +}); + +// ─── Cache Key Tests ───────────────────────────────────────── + +test("computeCacheKey is deterministic", () => { + const k1 = computeCacheKey("hello world", "auto", "web", 5); + const k2 = computeCacheKey("hello world", "auto", "web", 5); + assert.equal(k1, k2); +}); + +test("computeCacheKey normalizes query (case, whitespace)", () => { + const k1 = computeCacheKey("Hello World", "auto", "web", 5); + const k2 = computeCacheKey("hello world", "auto", "web", 5); + assert.equal(k1, k2); +}); + +test("computeCacheKey differs by provider", () => { + const k1 = computeCacheKey("test", "serper", "web", 5); + const k2 = computeCacheKey("test", "brave", "web", 5); + assert.notEqual(k1, k2); +}); + +test("computeCacheKey differs by search_type", () => { + const k1 = computeCacheKey("test", "auto", "web", 5); + const k2 = computeCacheKey("test", "auto", "news", 5); + assert.notEqual(k1, k2); +}); + +test("computeCacheKey differs by max_results", () => { + const k1 = computeCacheKey("test", "auto", "web", 5); + const k2 = computeCacheKey("test", "auto", "web", 10); + assert.notEqual(k1, k2); +}); + +// ─── Cache + Coalescing Tests ──────────────────────────────── + +test("getOrCoalesce caches and returns on second call", async () => { + let callCount = 0; + const key = "test-cache-hit-" + Date.now(); + + const r1 = await getOrCoalesce(key, 60_000, async () => { + callCount++; + return { value: 42 }; + }); + assert.equal(r1.cached, false); + assert.deepEqual(r1.data, { value: 42 }); + + const r2 = await getOrCoalesce(key, 60_000, async () => { + callCount++; + return { value: 99 }; + }); + assert.equal(r2.cached, true); + assert.deepEqual(r2.data, { value: 42 }); // original value, not 99 + assert.equal(callCount, 1); // fetchFn called only once +}); + +test("getOrCoalesce coalesces concurrent requests", async () => { + let callCount = 0; + const key = "test-coalesce-" + Date.now(); + + const fetchFn = async () => { + callCount++; + await new Promise((r) => setTimeout(r, 50)); // simulate async + return { value: "coalesced" }; + }; + + // Launch 3 concurrent requests with the same key + const [r1, r2, r3] = await Promise.all([ + getOrCoalesce(key, 60_000, fetchFn), + getOrCoalesce(key, 60_000, fetchFn), + getOrCoalesce(key, 60_000, fetchFn), + ]); + + assert.equal(callCount, 1); // Only one fetch executed + assert.deepEqual(r1.data, { value: "coalesced" }); + assert.deepEqual(r2.data, { value: "coalesced" }); + assert.deepEqual(r3.data, { value: "coalesced" }); +}); + +test("getOrCoalesce respects TTL=0 (no caching)", async () => { + let callCount = 0; + const key = "test-no-cache-" + Date.now(); + + await getOrCoalesce(key, 0, async () => { + callCount++; + return { value: 1 }; + }); + await getOrCoalesce(key, 0, async () => { + callCount++; + return { value: 2 }; + }); + + assert.equal(callCount, 2); // Both calls executed +}); + +test("getCacheStats returns valid stats", () => { + const stats = getCacheStats(); + assert.equal(typeof stats.size, "number"); + assert.equal(typeof stats.hits, "number"); + assert.equal(typeof stats.misses, "number"); +}); + +test("SEARCH_CACHE_DEFAULT_TTL_MS is positive", () => { + assert.ok(SEARCH_CACHE_DEFAULT_TTL_MS > 0); +}); + +// ─── Validation Schema Tests ──────────────────────────────── + +test("v1SearchSchema validates correct input", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ + query: "test query", + provider: "serper-search", + max_results: 10, + search_type: "web", + }); + assert.ok(result.success); + assert.equal(result.data.query, "test query"); + assert.equal(result.data.provider, "serper-search"); + assert.equal(result.data.max_results, 10); +}); + +test("v1SearchSchema rejects empty query", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ query: "" }); + assert.ok(!result.success); +}); + +test("v1SearchSchema rejects query over 500 chars", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ query: "a".repeat(501) }); + assert.ok(!result.success); +}); + +test("v1SearchSchema rejects invalid provider", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ query: "test", provider: "google" }); + assert.ok(!result.success); +}); + +test("v1SearchSchema accepts tavily provider", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ query: "test", provider: "tavily-search" }); + assert.ok(result.success); + assert.equal(result.data.provider, "tavily-search"); +}); + +test("v1SearchSchema applies defaults", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ query: "test" }); + assert.ok(result.success); + assert.equal(result.data.max_results, 5); + assert.equal(result.data.search_type, "web"); + assert.equal(result.data.provider, undefined); +}); + +test("v1SearchSchema allows unknown fields (forward compat)", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ + query: "test", + future_field: true, + }); + assert.ok(result.success); +});