import { UpstreamUnavailableError, SafetyBlockedError, RateLimitError } from '../domain/DomainError.js';
export interface GoogleSearchInput {
query: string;
instruction?: string;
model: string;
}
export interface GoogleSearchResponse {
result: string;
searchQueries: string[];
sources: Array<{
title: string;
url: string;
}>;
}
export class GoogleSearchGenAI {
constructor(private readonly apiKey: string) {}
async search(input: GoogleSearchInput): Promise<GoogleSearchResponse> {
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(input.model)}:generateContent`;
const promptText = `Role: You are a meticulous web researcher.
Primary directive:
- Perform grounded Google Search and for ANY URL you cite, you MUST fetch it via URL Context and synthesize findings.
- Prefer authoritative, up-to-date sources.
- If coverage is insufficient, refine the query and continue internally up to 5 rounds. Stop once adequate.
Task:${input.instruction ? `\n${input.instruction}` : ''}
Research focus: ${input.query}`;
const tools = [{ google_search: {} }, { url_context: {} }];
const body = {
contents: [
{
parts: [{ text: promptText }],
},
],
tools,
};
try {
const response = await fetch(endpoint + `?key=${encodeURIComponent(this.apiKey)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text();
this.mapAndThrowError(new Error(`Gemini API error ${response.status}: ${text}`));
}
const json = await response.json();
return this.parseSearchResponse(json);
} catch (error) {
this.mapAndThrowError(error as Error);
throw error; // This won't be reached due to mapAndThrowError throwing
}
}
private parseSearchResponse(json: any): GoogleSearchResponse {
const candidate = json?.candidates?.[0];
const textOut = candidate?.content?.parts?.map((p: any) => p?.text).filter(Boolean).join("\n") || "";
// Collect grounding metadata for search results
const groundingMeta = candidate?.grounding_metadata;
const searchQueries = groundingMeta?.web_search_queries || [];
const groundingChunks = groundingMeta?.grounding_chunks || [];
const sources = groundingChunks.map((chunk: any) => ({
title: chunk.web?.title || "(no title)",
url: chunk.web?.uri || "(no URL)"
}));
return {
result: textOut,
searchQueries,
sources
};
}
private mapAndThrowError(error: Error): void {
const errorMessage = error.message.toLowerCase();
if (errorMessage.includes('safety') || errorMessage.includes('blocked')) {
throw new SafetyBlockedError(error.message);
}
if (errorMessage.includes('rate limit') || errorMessage.includes('quota')) {
throw new RateLimitError(error.message);
}
if (errorMessage.includes('network') || errorMessage.includes('timeout') ||
errorMessage.includes('unavailable') || errorMessage.includes('connection')) {
throw new UpstreamUnavailableError(error.message, error);
}
throw new UpstreamUnavailableError(`Gemini API error: ${error.message}`, error);
}
}