import { getProviderCredentials, markAccountUnavailable, clearAccountError, extractApiKey, isValidApiKey, } from "../services/auth.js"; import { getSettings, getCombos } from "@/lib/localDb"; import { AI_PROVIDERS, resolveProviderId } from "@/shared/constants/providers.js"; import { handleSearchCore } from "open-sse/handlers/search/index.js"; import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; import * as log from "../utils/logger.js"; import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; import { handleComboChat, getComboModelsFromData } from "open-sse/services/combo.js"; /** * Handle web search request for the SSE/Next.js server. * Provider IS the model (no model field). Mirrors handleEmbeddings auth + fallback flow. * * @param {Request} request */ export async function handleSearch(request) { let body; try { body = await request.json(); } catch { log.warn("SEARCH", "Invalid JSON body"); return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); } const url = new URL(request.url); // Accept either `provider` or `model` (UI sends `model` since provider IS the model for webSearch) const providerInput = body.provider || body.model; const query = body.query; log.request("POST", `${url.pathname} | ${providerInput}`); // Log API key (masked) const apiKey = extractApiKey(request); if (apiKey) { log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`); } else { log.debug("AUTH", "No API key provided (local mode)"); } // Enforce API key if enabled in settings const settings = await getSettings(); if (settings.requireApiKey) { if (!apiKey) { log.warn("AUTH", "Missing API key (requireApiKey=true)"); return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); } const valid = await isValidApiKey(apiKey); if (!valid) { log.warn("AUTH", "Invalid API key (requireApiKey=true)"); return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); } } if (!providerInput || typeof providerInput !== "string") { log.warn("SEARCH", "Missing provider/model"); return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: provider (or model)"); } if (!query || typeof query !== "string" || !query.trim()) { log.warn("SEARCH", "Missing query"); return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: query"); } // Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers const combos = await getCombos(); const comboModels = getComboModelsFromData(providerInput, combos); if (comboModels) { const comboStrategies = settings.comboStrategies || {}; const comboStrategy = comboStrategies[providerInput]?.fallbackStrategy || settings.comboStrategy || "fallback"; log.info("SEARCH", `Combo "${providerInput}" with ${comboModels.length} providers (strategy: ${comboStrategy})`); return handleComboChat({ body, models: comboModels, handleSingleModel: (b, m) => handleSingleProviderSearch(b, m, request, apiKey, settings), log, comboName: providerInput, comboStrategy }); } return handleSingleProviderSearch(body, providerInput, request, apiKey, settings); } async function handleSingleProviderSearch(body, providerInput, request, apiKey, settings) { const query = body.query; const providerId = resolveProviderId(providerInput); const resolvedProvider = AI_PROVIDERS[providerId]; if (!resolvedProvider) { log.warn("SEARCH", "Unknown provider", { provider: providerInput }); return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown provider: ${providerInput}`); } const providerConfig = resolvedProvider.searchConfig; const supportsSearch = !!providerConfig || !!resolvedProvider.searchViaChat; if (!supportsSearch) { log.warn("SEARCH", "Provider does not support web search", { provider: providerId }); return errorResponse(HTTP_STATUS.BAD_REQUEST, `Provider ${providerId} does not support web search`); } if (providerInput !== providerId) { log.info("ROUTING", `${providerInput} → ${providerId}`); } else { log.info("ROUTING", `Provider: ${providerId}`); } // Sanitized body forwarded to core const coreBody = { query: query.trim(), provider: providerId, max_results: body.max_results, search_type: body.search_type, country: body.country, language: body.language, time_range: body.time_range, offset: body.offset, domain_filter: body.domain_filter, content_options: body.content_options, provider_options: body.provider_options }; // No-auth providers (e.g. searxng) bypass credential lookup if (resolvedProvider.noAuth) { log.info("AUTH", `\x1b[32m${providerId} no-auth mode\x1b[0m`); const result = await handleSearchCore({ body: coreBody, provider: resolvedProvider, providerConfig, credentials: null, log }); if (result.success) return result.response; return result.response; } // Credential + fallback loop const excludeConnectionIds = new Set(); let lastError = null; let lastStatus = null; while (true) { const credentials = await getProviderCredentials(providerId, excludeConnectionIds); if (!credentials || credentials.allRateLimited) { if (credentials?.allRateLimited) { const errorMsg = lastError || credentials.lastError || "Unavailable"; const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE; log.warn("SEARCH", `[${providerId}] ${errorMsg} (${credentials.retryAfterHuman})`); return unavailableResponse(status, `[${providerId}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman); } if (excludeConnectionIds.size === 0) { log.error("AUTH", `No credentials for provider: ${providerId}`); return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${providerId}`); } log.warn("SEARCH", "No more accounts available", { provider: providerId }); return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable"); } log.info("AUTH", `\x1b[32mUsing ${providerId} account: ${credentials.connectionName}\x1b[0m`); const refreshedCredentials = await checkAndRefreshToken(providerId, credentials); const result = await handleSearchCore({ body: coreBody, provider: resolvedProvider, providerConfig, credentials: refreshedCredentials, log, onCredentialsRefreshed: async (newCreds) => { await updateProviderCredentials(credentials.connectionId, { accessToken: newCreds.accessToken, refreshToken: newCreds.refreshToken, providerSpecificData: newCreds.providerSpecificData, testStatus: "active" }); }, onRequestSuccess: async () => { await clearAccountError(credentials.connectionId, credentials); } }); if (result.success) return result.response; const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, providerId); if (shouldFallback) { log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`); excludeConnectionIds.add(credentials.connectionId); lastError = result.error; lastStatus = result.status; continue; } return result.response; } }