Files
OmniRoute/src/lib/providers/validation.ts
Regis 564e983c68 feat(search): add unified web search routing with 5 providers
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)
2026-03-17 18:28:35 +01:00

606 lines
18 KiB
TypeScript

import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
import {
isAnthropicCompatibleProvider,
isOpenAICompatibleProvider,
} from "@/shared/constants/providers";
const OPENAI_LIKE_FORMATS = new Set(["openai", "openai-responses"]);
const GEMINI_LIKE_FORMATS = new Set(["gemini", "gemini-cli"]);
function normalizeBaseUrl(baseUrl: string) {
return (baseUrl || "").trim().replace(/\/$/, "");
}
function addModelsSuffix(baseUrl: string) {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) return "";
const suffixes = ["/chat/completions", "/responses", "/chat", "/messages"];
for (const suffix of suffixes) {
if (normalized.endsWith(suffix)) {
return `${normalized.slice(0, -suffix.length)}/models`;
}
}
return `${normalized}/models`;
}
function resolveBaseUrl(entry: any, providerSpecificData: any = {}) {
if (providerSpecificData?.baseUrl) return normalizeBaseUrl(providerSpecificData.baseUrl);
if (entry?.baseUrl) return normalizeBaseUrl(entry.baseUrl);
return "";
}
function resolveChatUrl(provider: string, baseUrl: string, providerSpecificData: any = {}) {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) return "";
if (isOpenAICompatibleProvider(provider)) {
if (providerSpecificData?.apiType === "responses") {
return `${normalized}/responses`;
}
return `${normalized}/chat/completions`;
}
if (
normalized.endsWith("/chat/completions") ||
normalized.endsWith("/responses") ||
normalized.endsWith("/chat")
) {
return normalized;
}
if (normalized.endsWith("/v1")) {
return `${normalized}/chat/completions`;
}
return normalized;
}
function buildBearerHeaders(apiKey: string) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
};
}
async function validateOpenAILikeProvider({
provider,
apiKey,
baseUrl,
providerSpecificData = {},
modelId = "gpt-4o-mini",
modelsUrl: customModelsUrl,
}) {
if (!baseUrl) {
return { valid: false, error: "Missing base URL" };
}
const modelsUrl = customModelsUrl || addModelsSuffix(baseUrl);
if (!modelsUrl) {
return { valid: false, error: "Invalid models endpoint" };
}
const modelsRes = await fetch(modelsUrl, {
method: "GET",
headers: buildBearerHeaders(apiKey),
});
if (modelsRes.ok) {
return { valid: true, error: null };
}
if (modelsRes.status === 401 || modelsRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
const chatUrl = resolveChatUrl(provider, baseUrl, providerSpecificData);
if (!chatUrl) {
return { valid: false, error: `Validation failed: ${modelsRes.status}` };
}
const testBody = {
model: modelId,
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
};
const chatRes = await fetch(chatUrl, {
method: "POST",
headers: buildBearerHeaders(apiKey),
body: JSON.stringify(testBody),
});
if (chatRes.ok) {
return { valid: true, error: null };
}
if (chatRes.status === 401 || chatRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
if (chatRes.status === 404 || chatRes.status === 405) {
return { valid: false, error: "Provider validation endpoint not supported" };
}
if (chatRes.status >= 500) {
return { valid: false, error: `Provider unavailable (${chatRes.status})` };
}
// 4xx other than auth (e.g., invalid model/body) usually means auth passed.
return { valid: true, error: null };
}
async function validateAnthropicLikeProvider({ apiKey, baseUrl, modelId, headers = {} }: any) {
if (!baseUrl) {
return { valid: false, error: "Missing base URL" };
}
const requestHeaders = {
"Content-Type": "application/json",
...headers,
};
if (!requestHeaders["x-api-key"] && !requestHeaders["X-API-Key"]) {
requestHeaders["x-api-key"] = apiKey;
}
if (!requestHeaders["anthropic-version"] && !requestHeaders["Anthropic-Version"]) {
requestHeaders["anthropic-version"] = "2023-06-01";
}
const response = await fetch(baseUrl, {
method: "POST",
headers: requestHeaders,
body: JSON.stringify({
model: modelId || "claude-3-5-sonnet-20241022",
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
}
return { valid: true, error: null };
}
async function validateGeminiLikeProvider({ apiKey, baseUrl }: any) {
if (!baseUrl) {
return { valid: false, error: "Missing base URL" };
}
const separator = baseUrl.includes("?") ? "&" : "?";
const response = await fetch(`${baseUrl}${separator}key=${encodeURIComponent(apiKey)}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
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}` };
}
// ── Specialty providers (non-standard APIs) ──
async function validateDeepgramProvider({ apiKey }: any) {
try {
const response = await fetch("https://api.deepgram.com/v1/auth/token", {
method: "GET",
headers: { Authorization: `Token ${apiKey}` },
});
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" };
}
}
async function validateAssemblyAIProvider({ apiKey }: any) {
try {
const response = await fetch("https://api.assemblyai.com/v2/transcript?limit=1", {
method: "GET",
headers: {
Authorization: apiKey,
"Content-Type": "application/json",
},
});
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" };
}
}
async function validateNanoBananaProvider({ apiKey }: any) {
try {
// NanoBanana doesn't expose a lightweight validation endpoint,
// so we send a minimal generate request that will succeed or fail on auth.
const response = await fetch("https://api.nanobananaapi.ai/api/v1/nanobanana/generate", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: "test",
model: "nanobanana-flash",
}),
});
// Auth errors → 401/403; anything else (even 400 bad request) means auth passed
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
}
return { valid: true, error: null };
} catch (error: any) {
return { valid: false, error: error.message || "Validation failed" };
}
}
async function validateElevenLabsProvider({ apiKey }: any) {
try {
// Lightweight auth check endpoint
const response = await fetch("https://api.elevenlabs.io/v1/voices", {
method: "GET",
headers: {
"xi-api-key": apiKey,
"Content-Type": "application/json",
},
});
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" };
}
}
async function validateInworldProvider({ apiKey }: any) {
try {
// Inworld TTS lacks a simple key-introspection endpoint.
// Send a minimal synth request and treat non-auth 4xx as auth-pass.
const response = await fetch("https://api.inworld.ai/tts/v1/voice", {
method: "POST",
headers: {
Authorization: `Basic ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
text: "test",
modelId: "inworld-tts-1.5-mini",
audioConfig: { audioEncoding: "MP3" },
}),
});
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
}
// Any other response indicates auth is accepted (payload/model may still be wrong)
return { valid: true, error: null };
} catch (error: any) {
return { valid: false, error: error.message || "Validation failed" };
}
}
async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = {} }: any) {
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
if (!baseUrl) {
return { valid: false, error: "No base URL configured for OpenAI compatible provider" };
}
// Step 1: Try GET /models
try {
const modelsRes = await fetch(`${baseUrl}/models`, {
method: "GET",
headers: buildBearerHeaders(apiKey),
});
if (modelsRes.ok) {
return { valid: true, error: null };
}
if (modelsRes.status === 401 || modelsRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
} catch {
// /models fetch failed (network error, etc.) — fall through to chat test
}
// Step 2: Fallback — try a minimal chat completion request
// Many providers don't expose /models but accept chat completions fine
const apiType = providerSpecificData.apiType || "chat";
const chatSuffix = apiType === "responses" ? "/responses" : "/chat/completions";
const chatUrl = `${baseUrl}${chatSuffix}`;
try {
const chatRes = await fetch(chatUrl, {
method: "POST",
headers: buildBearerHeaders(apiKey),
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
}),
});
if (chatRes.ok) {
return { valid: true, error: null };
}
if (chatRes.status === 401 || chatRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
// 4xx other than auth (e.g. 400 bad model, 422) usually means auth passed
if (chatRes.status >= 400 && chatRes.status < 500) {
return { valid: true, error: null };
}
if (chatRes.status >= 500) {
return { valid: false, error: `Provider unavailable (${chatRes.status})` };
}
} catch {
// Chat test also failed — fall through to simple connectivity check
}
// Step 3: Final fallback — simple connectivity check
// For local providers (Ollama, LM Studio, etc.) that may not respond to
// standard OpenAI endpoints but are still reachable
try {
const pingRes = await fetch(baseUrl, {
method: "GET",
headers: buildBearerHeaders(apiKey),
signal: AbortSignal.timeout(5000),
});
// If the server responds at all (even with an error page), it's reachable
if (pingRes.status < 500) {
return { valid: true, error: null };
}
return { valid: false, error: `Provider unavailable (${pingRes.status})` };
} catch (error: any) {
return { valid: false, error: error.message || "Connection failed" };
}
}
async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificData = {} }: any) {
let baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
if (!baseUrl) {
return { valid: false, error: "No base URL configured for Anthropic compatible provider" };
}
if (baseUrl.endsWith("/messages")) {
baseUrl = baseUrl.slice(0, -9);
}
const headers = {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
Authorization: `Bearer ${apiKey}`,
};
// Step 1: Try GET /models
try {
const modelsRes = await fetch(`${baseUrl}/models`, {
method: "GET",
headers,
});
if (modelsRes.ok) {
return { valid: true, error: null };
}
if (modelsRes.status === 401 || modelsRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
} catch {
// /models fetch failed — fall through to messages test
}
// Step 2: Fallback — try a minimal messages request
try {
const messagesRes = await fetch(`${baseUrl}/messages`, {
method: "POST",
headers,
body: JSON.stringify({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
if (messagesRes.status === 401 || messagesRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
// Any other response (200, 400, 422, etc.) means auth passed
return { valid: true, error: null };
} catch (error: any) {
return { valid: false, error: error.message || "Connection failed" };
}
}
// ── 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 };
}
if (isOpenAICompatibleProvider(provider)) {
try {
return await validateOpenAICompatibleProvider({ apiKey, providerSpecificData });
} catch (error: any) {
return { valid: false, error: error.message || "Validation failed", unsupported: false };
}
}
if (isAnthropicCompatibleProvider(provider)) {
try {
return await validateAnthropicCompatibleProvider({ apiKey, providerSpecificData });
} catch (error: any) {
return { valid: false, error: error.message || "Validation failed", unsupported: false };
}
}
// ── Specialty provider validation ──
const SPECIALTY_VALIDATORS = {
deepgram: validateDeepgramProvider,
assemblyai: validateAssemblyAIProvider,
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]) {
try {
return await SPECIALTY_VALIDATORS[provider]({ apiKey, providerSpecificData });
} catch (error: any) {
return { valid: false, error: error.message || "Validation failed", unsupported: false };
}
}
const entry = getRegistryEntry(provider);
if (!entry) {
return { valid: false, error: "Provider validation not supported", unsupported: true };
}
const modelId = entry.models?.[0]?.id || null;
const baseUrl = resolveBaseUrl(entry, providerSpecificData);
try {
if (OPENAI_LIKE_FORMATS.has(entry.format)) {
return await validateOpenAILikeProvider({
provider,
apiKey,
baseUrl,
providerSpecificData,
modelId,
modelsUrl: entry.modelsUrl,
});
}
if (entry.format === "claude") {
const requestBaseUrl = `${baseUrl}${entry.urlSuffix || ""}`;
const requestHeaders = {
...(entry.headers || {}),
};
if ((entry.authHeader || "").toLowerCase() === "x-api-key") {
requestHeaders["x-api-key"] = apiKey;
} else {
requestHeaders["Authorization"] = `Bearer ${apiKey}`;
}
return await validateAnthropicLikeProvider({
apiKey,
baseUrl: requestBaseUrl,
modelId,
headers: requestHeaders,
});
}
if (GEMINI_LIKE_FORMATS.has(entry.format)) {
return await validateGeminiLikeProvider({
apiKey,
baseUrl,
});
}
return { valid: false, error: "Provider validation not supported", unsupported: true };
} catch (error: any) {
return { valid: false, error: error.message || "Validation failed", unsupported: false };
}
}