mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 10:43:43 +03:00
Co-authored-by: SupremeNexas <SupremeNexas@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
292 lines
9.3 KiB
TypeScript
292 lines
9.3 KiB
TypeScript
import { getProviderCredentials } from "@/sse/services/auth";
|
|
import { recordCost } from "@/domain/costRules";
|
|
import * as defaultLog from "@/sse/utils/logger";
|
|
import {
|
|
getAllSearchProviders,
|
|
getSearchProvider,
|
|
selectProvider,
|
|
supportsSearchType,
|
|
SEARCH_CREDENTIAL_FALLBACKS,
|
|
SEARCH_PROVIDERS,
|
|
type SearchProviderConfig,
|
|
} from "@omniroute/open-sse/config/searchRegistry.ts";
|
|
import { handleSearch, type SearchResponse } from "@omniroute/open-sse/handlers/search.ts";
|
|
import {
|
|
computeCacheKey,
|
|
getOrCoalesce,
|
|
SEARCH_CACHE_DEFAULT_TTL_MS,
|
|
} from "@omniroute/open-sse/services/searchCache.ts";
|
|
|
|
type SearchLogger = typeof defaultLog;
|
|
|
|
export interface ExecuteWebSearchInput {
|
|
query: string;
|
|
provider?: string;
|
|
max_results?: number;
|
|
limit?: number;
|
|
search_type?: "web" | "news";
|
|
offset?: number;
|
|
country?: string;
|
|
language?: string;
|
|
time_range?: "any" | "day" | "week" | "month" | "year";
|
|
content?: {
|
|
snippet?: boolean;
|
|
full_page?: boolean;
|
|
format?: "text" | "markdown";
|
|
max_characters?: number;
|
|
};
|
|
filters?: {
|
|
include_domains?: string[];
|
|
exclude_domains?: string[];
|
|
safe_search?: "off" | "moderate" | "strict";
|
|
};
|
|
provider_options?: Record<string, unknown>;
|
|
strict_filters?: boolean;
|
|
apiKeyId?: string | null;
|
|
log?: SearchLogger;
|
|
}
|
|
|
|
export interface ExecuteWebSearchResult {
|
|
cached: boolean;
|
|
data: SearchResponse;
|
|
}
|
|
|
|
export class WebSearchExecutionError extends Error {
|
|
statusCode: number;
|
|
|
|
constructor(message: string, statusCode: number) {
|
|
super(message);
|
|
this.statusCode = statusCode;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function normalizeMaxResults(input: ExecuteWebSearchInput, providerConfig: SearchProviderConfig) {
|
|
const fromMaxResults =
|
|
typeof input.max_results === "number"
|
|
? input.max_results
|
|
: typeof input.max_results === "string"
|
|
? Number(input.max_results)
|
|
: Number.NaN;
|
|
const fromLimit =
|
|
typeof input.limit === "number"
|
|
? input.limit
|
|
: typeof input.limit === "string"
|
|
? Number(input.limit)
|
|
: Number.NaN;
|
|
const requested = Number.isFinite(fromMaxResults)
|
|
? fromMaxResults
|
|
: Number.isFinite(fromLimit)
|
|
? fromLimit
|
|
: providerConfig.defaultMaxResults;
|
|
return Math.min(Math.max(1, requested), providerConfig.maxMaxResults);
|
|
}
|
|
|
|
function assertValidSearchInput(input: ExecuteWebSearchInput) {
|
|
if (typeof input.query !== "string" || input.query.trim().length === 0) {
|
|
throw new WebSearchExecutionError("Missing required field: query", 400);
|
|
}
|
|
if (input.query.trim().length > 500) {
|
|
throw new WebSearchExecutionError("Query must be 500 characters or fewer", 400);
|
|
}
|
|
if (input.search_type && input.search_type !== "web" && input.search_type !== "news") {
|
|
throw new WebSearchExecutionError(`Unsupported search_type: ${String(input.search_type)}`, 400);
|
|
}
|
|
}
|
|
|
|
export async function executeWebSearch(
|
|
input: ExecuteWebSearchInput
|
|
): Promise<ExecuteWebSearchResult> {
|
|
assertValidSearchInput(input);
|
|
|
|
const log = input.log || defaultLog;
|
|
const searchType = input.search_type || "web";
|
|
|
|
if (input.provider) {
|
|
const explicitProvider = getSearchProvider(input.provider);
|
|
if (!explicitProvider) {
|
|
throw new WebSearchExecutionError(`Unknown search provider: ${input.provider}`, 400);
|
|
}
|
|
if (!supportsSearchType(explicitProvider, searchType)) {
|
|
throw new WebSearchExecutionError(
|
|
`Search provider ${input.provider} does not support search_type: ${searchType}`,
|
|
400
|
|
);
|
|
}
|
|
}
|
|
|
|
let providerConfig = selectProvider(input.provider, searchType);
|
|
if (!providerConfig) {
|
|
throw new WebSearchExecutionError(
|
|
input.provider
|
|
? `Unknown search provider: ${input.provider}`
|
|
: `No search providers available. Add an API key for a search provider (${getAllSearchProviders()
|
|
.map((provider) => provider.id)
|
|
.join(", ")}) in the dashboard.`,
|
|
400
|
|
);
|
|
}
|
|
|
|
let credentials: Record<string, any> | null = null;
|
|
let alternateProviderId: string | undefined;
|
|
let alternateCredentials: Record<string, any> | null = null;
|
|
|
|
if (input.provider) {
|
|
credentials = await resolveSearchCredentials(providerConfig.id);
|
|
if (
|
|
!credentials &&
|
|
providerConfig.authType === "none" &&
|
|
typeof input.provider_options?.baseUrl === "string" &&
|
|
input.provider_options.baseUrl.trim().length > 0
|
|
) {
|
|
credentials = {
|
|
providerSpecificData: { baseUrl: input.provider_options.baseUrl.trim() },
|
|
};
|
|
}
|
|
if (!credentials) {
|
|
throw new WebSearchExecutionError(
|
|
providerConfig.authType === "none"
|
|
? `Search provider ${providerConfig.id} is not configured. Set its base URL in the dashboard or pass provider_options.baseUrl.`
|
|
: `No credentials configured for search provider: ${providerConfig.id}. Add an API key for "${providerConfig.id}" in the dashboard.`,
|
|
400
|
|
);
|
|
}
|
|
} else {
|
|
credentials = await resolveSearchCredentials(providerConfig.id);
|
|
|
|
if (!credentials) {
|
|
const fallbackProviders = Object.values(SEARCH_PROVIDERS)
|
|
.filter((provider) => provider.fallbackOnly && supportsSearchType(provider, searchType))
|
|
.sort((a, b) => a.costPerQuery - b.costPerQuery);
|
|
|
|
for (const fallbackProvider of fallbackProviders) {
|
|
providerConfig = fallbackProvider;
|
|
if (fallbackProvider.id === "duckduckgo-free") {
|
|
credentials = {};
|
|
break;
|
|
}
|
|
const fallbackCredentials = await resolveSearchCredentials(fallbackProvider.id);
|
|
if (fallbackCredentials) {
|
|
credentials = fallbackCredentials;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!credentials) {
|
|
const sortedIds = Object.values(SEARCH_PROVIDERS)
|
|
.filter((provider) => supportsSearchType(provider, searchType))
|
|
.sort((a, b) => a.costPerQuery - b.costPerQuery)
|
|
.map((provider) => provider.id);
|
|
|
|
for (const providerId of sortedIds) {
|
|
if (providerId === providerConfig.id) continue;
|
|
const altConfig = getSearchProvider(providerId);
|
|
const altCreds = await resolveSearchCredentials(providerId);
|
|
if (altConfig && altCreds) {
|
|
providerConfig = altConfig;
|
|
credentials = altCreds;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!credentials) {
|
|
throw new WebSearchExecutionError(
|
|
`No credentials configured for any search provider. Add an API key for a search provider (${Object.keys(
|
|
SEARCH_PROVIDERS
|
|
).join(", ")}) in the dashboard.`,
|
|
400
|
|
);
|
|
}
|
|
|
|
const otherIds = Object.values(SEARCH_PROVIDERS)
|
|
.filter((provider) => supportsSearchType(provider, searchType))
|
|
.sort((a, b) => a.costPerQuery - b.costPerQuery)
|
|
.map((provider) => provider.id)
|
|
.filter((providerId) => providerId !== providerConfig.id);
|
|
|
|
for (const providerId of otherIds) {
|
|
const creds = await resolveSearchCredentials(providerId);
|
|
if (creds) {
|
|
alternateProviderId = providerId;
|
|
alternateCredentials = creds;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const clampedMaxResults = normalizeMaxResults(input, providerConfig);
|
|
const cacheKey = computeCacheKey(
|
|
input.query.trim(),
|
|
providerConfig.id,
|
|
searchType,
|
|
clampedMaxResults,
|
|
input.country,
|
|
input.language,
|
|
{
|
|
filters: input.filters,
|
|
offset: input.offset,
|
|
time_range: input.time_range,
|
|
}
|
|
);
|
|
const ttl = providerConfig.cacheTTLMs ?? SEARCH_CACHE_DEFAULT_TTL_MS;
|
|
|
|
const { data, cached } = await getOrCoalesce(cacheKey, ttl, async () => {
|
|
const result = await handleSearch({
|
|
query: input.query.trim(),
|
|
provider: providerConfig.id,
|
|
maxResults: clampedMaxResults,
|
|
searchType,
|
|
country: input.country,
|
|
language: input.language,
|
|
timeRange: input.time_range,
|
|
offset: input.offset,
|
|
domainFilter: buildDomainFilter(input.filters),
|
|
contentOptions: input.content,
|
|
strictFilters: input.strict_filters,
|
|
providerOptions: input.provider_options,
|
|
credentials,
|
|
alternateProvider: alternateProviderId,
|
|
alternateCredentials,
|
|
log,
|
|
connectionId: credentials?.connectionId || undefined,
|
|
apiKeyId: input.apiKeyId || undefined,
|
|
});
|
|
|
|
if (!result.success || !result.data) {
|
|
throw new WebSearchExecutionError(result.error || "Search failed", result.status || 502);
|
|
}
|
|
|
|
return result.data;
|
|
});
|
|
|
|
if (!cached && input.apiKeyId && input.apiKeyId !== "local" && data.usage?.search_cost_usd > 0) {
|
|
try {
|
|
recordCost(input.apiKeyId, data.usage.search_cost_usd);
|
|
} catch (error: any) {
|
|
log.warn("SEARCH", `Cost recording failed: ${error?.message || String(error)}`);
|
|
}
|
|
}
|
|
|
|
return { data, cached };
|
|
}
|