import { CredentialResolutionError } from "../../config/credentials.ts"; import { getConfig } from "../../config/store.ts"; import { activityMonitor } from "../../observability/activity.ts"; import type { SearchProviderAdapter } from "../../search/provider.ts"; import type { SearchOptions, SearchResponse, SearchResult } from "../../search/types.ts"; import { getApiKey, getVersionedApiBase, fetchGeminiApi, isGatewayConfigured, isGeminiApiAvailable, redactGeminiApiResponse } from "./api.ts"; import { isGeminiAdcAvailable } from "./adc.ts"; import { getGeminiWebAvailabilityDiagnostic, isGeminiWebAvailable, queryWithCookies } from "./web.ts"; const DEFAULT_SEARCH_MODEL = "gemini-3.6-flash"; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isAbortError(error: unknown): boolean { return errorMessage(error).toLowerCase().includes("abort"); } function searchModel(): string { const configured = getConfig().searchModel; return typeof configured === "string" && configured.trim() ? configured.trim() : DEFAULT_SEARCH_MODEL; } export const geminiSearchProvider: SearchProviderAdapter = { id: "gemini", label: "Gemini", isAvailable: isGeminiSearchAvailable, search: searchWithGemini, isAvailableForAll: isGeminiApiAvailable, async searchForAll(query, options) { const result = await searchWithGeminiApi(query, options); if (!result) throw new Error("Gemini API search returned no results."); return result; }, }; async function isGeminiSearchAvailable(): Promise { if (isGeminiApiAvailable()) return true; try { return !!(await isGeminiWebAvailable()); } catch { return false; } } async function searchWithGemini(query: string, options: SearchOptions, strictErrors = true): Promise { const errors: string[] = []; try { const result = await searchWithGeminiApi(query, options); if (result) return result; } catch (error) { if (error instanceof CredentialResolutionError || isAbortError(error)) throw error; errors.push(`Gemini API: ${errorMessage(error)}`); } try { const result = await searchWithGeminiWeb(query, options); if (result) return result; const diagnostic = getGeminiWebAvailabilityDiagnostic(); if (diagnostic) errors.push(`Gemini Web: ${diagnostic}`); } catch (error) { if (isAbortError(error)) throw error; errors.push(`Gemini Web: ${errorMessage(error)}`); } if (strictErrors && errors.length > 0) throw new Error(`Gemini search failed:\n - ${errors.join("\n - ")}`); throw new Error("Gemini search returned no results"); } async function searchWithGeminiApi(query: string, options: SearchOptions = {}): Promise { const requestSignal = AbortSignal.any([AbortSignal.timeout(120_000), ...(options.signal ? [options.signal] : [])]); const apiKey = isGeminiAdcAvailable() ? null : await getApiKey(requestSignal); if (!apiKey && !isGatewayConfigured() && !isGeminiAdcAvailable()) return null; const activityId = activityMonitor.logStart({ type: "api", query }); try { const response = await fetchGeminiApi(`${getVersionedApiBase()}/models/${searchModel()}:generateContent`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contents: [{ role: "user", parts: [{ text: query }] }], tools: [{ google_search: {} }] }), signal: requestSignal, }, apiKey); if (!response.ok) { const text = redactGeminiApiResponse(response, await response.text(), apiKey); throw new Error(`Gemini API error ${response.status}: ${text.slice(0, 300)}`); } const data = await response.json() as GeminiSearchResponse; activityMonitor.logComplete(activityId, response.status); const answer = data.candidates?.[0]?.content?.parts?.map(part => part.text).filter(Boolean).join("\n") ?? ""; const results = await resolveGroundingChunks(data.candidates?.[0]?.groundingMetadata?.groundingChunks, options.signal); return answer || results.length > 0 ? { answer, results } : null; } catch (error) { if (isAbortError(error)) activityMonitor.logComplete(activityId, 0); else activityMonitor.logError(activityId, errorMessage(error)); throw error; } } async function searchWithGeminiWeb(query: string, options: SearchOptions): Promise { const cookies = await isGeminiWebAvailable(); if (!cookies) return null; const activityId = activityMonitor.logStart({ type: "api", query }); try { const answer = await queryWithCookies(buildPrompt(query, options), cookies, { signal: options.signal, timeoutMs: 120_000 }); activityMonitor.logComplete(activityId, 200); return { answer, results: extractSourceUrls(answer) }; } catch (error) { if (isAbortError(error)) activityMonitor.logComplete(activityId, 0); else activityMonitor.logError(activityId, errorMessage(error)); throw error; } } function buildPrompt(query: string, options: SearchOptions): string { let prompt = `Search the web and answer the following question. Include source URLs for your claims.\nFormat your response as:\n1. A direct answer to the question\n2. Cited sources as markdown links\n\nQuestion: ${query}`; if (options.recencyFilter) { const labels = { day: "past 24 hours", week: "past week", month: "past month", year: "past year" } as const; prompt += `\n\nOnly include results from the ${labels[options.recencyFilter]}.`; } if (options.domainFilter?.length) { const included = options.domainFilter.filter(domain => !domain.startsWith("-")); const excluded = options.domainFilter.filter(domain => domain.startsWith("-")).map(domain => domain.slice(1)); if (included.length) prompt += `\n\nOnly cite sources from: ${included.join(", ")}`; if (excluded.length) prompt += `\n\nDo not cite sources from: ${excluded.join(", ")}`; } return prompt; } function extractSourceUrls(markdown: string): SearchResult[] { const results: SearchResult[] = []; const seen = new Set(); for (const match of markdown.matchAll(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g)) { if (seen.has(match[2])) continue; seen.add(match[2]); results.push({ title: match[1], url: match[2], snippet: "" }); } return results; } async function resolveGroundingChunks(chunks: GroundingChunk[] | undefined, signal?: AbortSignal): Promise { if (!chunks?.length) return []; const results: SearchResult[] = []; for (const chunk of chunks) { if (!chunk.web) continue; let url = chunk.web.uri || ""; if (url.includes("vertexaisearch.cloud.google.com/grounding-api-redirect")) url = await resolveRedirect(url, signal) ?? url; if (url) results.push({ title: chunk.web.title || "", url, snippet: "" }); } return results; } async function resolveRedirect(url: string, signal?: AbortSignal): Promise { try { const response = await fetch(url, { method: "HEAD", redirect: "manual", signal: AbortSignal.any([AbortSignal.timeout(5_000), ...(signal ? [signal] : [])]) }); return response.headers.get("location"); } catch { return null; } } type GeminiSearchResponse = { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> }; groundingMetadata?: { groundingChunks?: GroundingChunk[] }; }>; }; type GroundingChunk = { web?: { uri?: string; title?: string } };