mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
- Updated refreshCredentials methods in various executors (Antigravity, Base, Default, Github, Kiro) to accept optional proxyOptions for improved proxy handling. - Modified token refresh logic to utilize proxy-aware fetch for better network management. - Enhanced usage retrieval functions to support proxy options, ensuring seamless integration with proxy configurations. - Updated ModelSelectModal and ProviderInfoCard components to incorporate kind filtering for improved user experience in model selection. - Added validation for API keys in the provider validation route, including support for webSearch/webFetch providers.
202 lines
7.3 KiB
JavaScript
202 lines
7.3 KiB
JavaScript
/**
|
|
* Search Dispatcher — routes /v1/search requests to dedicated search APIs
|
|
* or chat-based LLM search wrappers, with retry-friendly error envelope.
|
|
*
|
|
* Dependency map:
|
|
* provider.searchConfig → dedicated search API (callers + normalizers)
|
|
* provider.searchViaChat → wrap chat-completions (chatSearch.js)
|
|
*/
|
|
|
|
import { buildSearchRequest } from "./callers.js";
|
|
import { normalizeSearchResponse } from "./normalizers.js";
|
|
import { handleChatSearch } from "./chatSearch.js";
|
|
|
|
const GLOBAL_TIMEOUT_MS = 15000;
|
|
const NON_RETRIABLE = new Set([400, 401, 403, 404]);
|
|
|
|
const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
|
|
|
|
/** Normalize and validate query string. */
|
|
function sanitizeQuery(query) {
|
|
if (CONTROL_CHAR_RE.test(query)) return { error: "Query contains invalid control characters" };
|
|
const clean = query.normalize("NFKC").trim().replace(/\s+/g, " ");
|
|
if (!clean) return { error: "Query is empty after normalization" };
|
|
return { clean };
|
|
}
|
|
|
|
// Strip non-ASCII chars from header values (HTTP headers must be ByteString).
|
|
function sanitizeHeaders(headers) {
|
|
if (!headers) return headers;
|
|
const out = {};
|
|
for (const [k, v] of Object.entries(headers)) {
|
|
out[k] = typeof v === "string" ? v.replace(/[^\x00-\xFF]/g, "").trim() : v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Build a JSON Response wrapper used by the auth layer. */
|
|
function jsonResponse(payload, status = 200) {
|
|
return new Response(JSON.stringify(payload), {
|
|
status,
|
|
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
|
|
});
|
|
}
|
|
|
|
/** Wrap an error result with a Response object so the auth wrapper can return it directly. */
|
|
function errorResult(status, error) {
|
|
return {
|
|
success: false,
|
|
status,
|
|
error,
|
|
response: jsonResponse({ error: { message: error, code: status } }, status)
|
|
};
|
|
}
|
|
|
|
/** Wrap a success payload. */
|
|
function successResult(data) {
|
|
return { success: true, data, response: jsonResponse(data, 200) };
|
|
}
|
|
|
|
/**
|
|
* Run a single dedicated search provider attempt.
|
|
* @returns {Promise<{success:boolean, status?:number, error?:string, data?:object}>}
|
|
*/
|
|
async function tryDedicatedProvider({ provider, providerConfig, body, credentials, log, globalStartTime }) {
|
|
const startTime = Date.now();
|
|
const token = credentials?.apiKey || credentials?.accessToken || undefined;
|
|
|
|
if (providerConfig.authType !== "none" && !token) {
|
|
return { success: false, status: 401, error: `No credentials for provider: ${provider.id}` };
|
|
}
|
|
|
|
const params = {
|
|
query: body.query,
|
|
searchType: body.search_type || (providerConfig.searchTypes?.[0] || "web"),
|
|
maxResults: Math.min(body.max_results || providerConfig.defaultMaxResults || 5, providerConfig.maxMaxResults || 100),
|
|
token,
|
|
country: body.country,
|
|
language: body.language,
|
|
timeRange: body.time_range,
|
|
offset: body.offset,
|
|
domainFilter: body.domain_filter,
|
|
contentOptions: body.content_options,
|
|
providerOptions: body.provider_options,
|
|
providerSpecificData: credentials?.providerSpecificData
|
|
};
|
|
|
|
let url, init;
|
|
try {
|
|
({ url, init } = buildSearchRequest({ id: provider.id, ...providerConfig }, params));
|
|
} catch (err) {
|
|
return { success: false, status: 400, error: err?.message || `Invalid request for ${provider.id}` };
|
|
}
|
|
|
|
// Timeout = min(provider timeout, remaining global)
|
|
const remaining = GLOBAL_TIMEOUT_MS - (Date.now() - globalStartTime);
|
|
const timeout = Math.min(providerConfig.timeoutMs || 10000, Math.max(remaining, 1000));
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
|
|
log?.info?.("SEARCH", `${provider.id} | "${params.query.slice(0, 80)}" | type=${params.searchType}`);
|
|
|
|
try {
|
|
const resp = await fetch(url, { ...init, headers: sanitizeHeaders(init.headers), signal: controller.signal });
|
|
clearTimeout(timer);
|
|
if (!resp.ok) {
|
|
const errText = await resp.text().catch(() => "");
|
|
log?.error?.("SEARCH", `${provider.id} ${resp.status}: ${errText.slice(0, 200)}`);
|
|
return { success: false, status: resp.status, error: `${provider.id} returned ${resp.status}: ${errText.slice(0, 200)}` };
|
|
}
|
|
const data = await resp.json();
|
|
const normalized = normalizeSearchResponse(provider.id, data, params.query, params.searchType);
|
|
const results = normalized.results.slice(0, params.maxResults);
|
|
const duration = Date.now() - startTime;
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
provider: provider.id,
|
|
query: params.query,
|
|
results,
|
|
answer: null,
|
|
usage: { queries_used: 1, search_cost_usd: providerConfig.costPerQuery || 0 },
|
|
metrics: { response_time_ms: duration, upstream_latency_ms: duration, total_results_available: normalized.totalResults },
|
|
errors: []
|
|
}
|
|
};
|
|
} catch (err) {
|
|
clearTimeout(timer);
|
|
const isTimeout = err.name === "AbortError";
|
|
const status = isTimeout ? 504 : 502;
|
|
log?.error?.("SEARCH", `${provider.id} ${isTimeout ? "timeout" : "error"}: ${err.message}`);
|
|
return { success: false, status, error: `${provider.id} ${isTimeout ? "timeout" : "error"}: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Core search handler. Dispatches to dedicated API or chat-based LLM.
|
|
* Same calling convention as handleEmbeddingsCore: returns `{success, response, status?, error?}`.
|
|
*
|
|
* @param {object} options
|
|
* @param {object} options.body Sanitized body from auth wrapper
|
|
* @param {object} options.provider Provider entry from AI_PROVIDERS
|
|
* @param {object} [options.providerConfig] Provider's searchConfig (if dedicated)
|
|
* @param {object|null} options.credentials Provider credentials
|
|
* @param {object} [options.log] Logger
|
|
*/
|
|
export async function handleSearchCore({ body, provider, providerConfig, credentials, log }) {
|
|
const globalStartTime = Date.now();
|
|
|
|
// 1. Sanitize query
|
|
const { clean, error: sanitizeError } = sanitizeQuery(body.query || "");
|
|
if (sanitizeError) return errorResult(400, sanitizeError);
|
|
const normalizedBody = { ...body, query: clean };
|
|
|
|
// 2. Route: dedicated search API takes priority over chat-based
|
|
let result;
|
|
if (providerConfig) {
|
|
result = await tryDedicatedProvider({
|
|
provider,
|
|
providerConfig,
|
|
body: normalizedBody,
|
|
credentials,
|
|
log,
|
|
globalStartTime
|
|
});
|
|
} else if (provider.searchViaChat) {
|
|
result = await handleChatSearch({
|
|
provider: provider.id,
|
|
query: clean,
|
|
maxResults: normalizedBody.max_results,
|
|
model: provider.searchViaChat.defaultModel,
|
|
credentials,
|
|
log
|
|
});
|
|
} else {
|
|
return errorResult(400, `Provider ${provider.id} does not support web search`);
|
|
}
|
|
|
|
if (result.success) return successResult(result.data);
|
|
|
|
// 3. Failover within global timeout for retriable errors
|
|
if (
|
|
!NON_RETRIABLE.has(result.status || 0) &&
|
|
Date.now() - globalStartTime < GLOBAL_TIMEOUT_MS &&
|
|
provider.searchViaChat &&
|
|
providerConfig
|
|
) {
|
|
log?.warn?.("SEARCH", `${provider.id} dedicated failed (${result.status}), falling back to chat-based search`);
|
|
const fallback = await handleChatSearch({
|
|
provider: provider.id,
|
|
query: clean,
|
|
maxResults: normalizedBody.max_results,
|
|
model: provider.searchViaChat.defaultModel,
|
|
credentials,
|
|
log
|
|
});
|
|
if (fallback.success) return successResult(fallback.data);
|
|
}
|
|
|
|
return errorResult(result.status || 502, result.error || "Search failed");
|
|
}
|