mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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)
606 lines
18 KiB
TypeScript
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 };
|
|
}
|
|
}
|