// Google provider module implements model/runtime integration. import { createProviderHttpError, formatProviderHttpErrorMessage, readProviderJsonObjectResponse, } from "openclaw/plugin-sdk/provider-http"; import { buildSearchCacheKey, buildUnsupportedSearchFilterResponse, DEFAULT_SEARCH_COUNT, MAX_SEARCH_COUNT, parseWebSearchTimeFilters, readCachedSearchPayload, readConfiguredSecretString, readPositiveIntegerParam, readProviderEnvValue, readStringParam, resolveCitationRedirectUrl, resolveSearchCacheTtlMs, resolveSearchCount, resolveSearchTimeoutSeconds, type SearchConfigRecord, withTrustedWebSearchEndpoint, wrapWebContent, writeCachedSearchPayload, } from "openclaw/plugin-sdk/provider-web-search"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveGeminiConfig, resolveGeminiBaseUrl, resolveGeminiModel, type GeminiConfig, } from "./gemini-web-search-provider.shared.js"; type GeminiFreshness = "day" | "week" | "month" | "year"; type GeminiTimeRangeFilter = { startTime: string; endTime: string; }; type GeminiGroundingResponse = { candidates?: Array<{ content?: { parts?: Array<{ text?: string; }>; }; groundingMetadata?: { groundingChunks?: Array<{ web?: { uri?: string; title?: string; }; }>; }; }>; error?: { code?: number; message?: string; status?: string; }; }; function throwMalformedGeminiResponse(): never { throw new Error("Gemini API error: malformed JSON response"); } const GEMINI_FRESHNESS_DAYS: Record = { day: 1, week: 7, month: 30, year: 365, }; const GEMINI_DAY_FRESHNESS_HINT = "Prioritize web sources published in the last 24 hours."; // Gemini's google_search.time_range_filter accepts second-precision RFC 3339 // only. Despite the underlying google.protobuf.Timestamp type accepting "0, 3, // 6 or 9 fractional digits", the Search grounding endpoint rejects any // non-zero fractional component with // "[FIELD_INVALID] Granularity of nano is not supported". // Strip the fractional-second component before serializing. function toGeminiTimeRangeTimestamp(date: Date): string { return date.toISOString().replace(/\.\d+Z$/, "Z"); } function isoDateStart(value: string): string { return `${value}T00:00:00Z`; } function isoDateExclusiveEnd(value: string): string { const end = new Date(`${value}T00:00:00Z`); end.setUTCDate(end.getUTCDate() + 1); return toGeminiTimeRangeTimestamp(end); } function freshnessStartTime(freshness: GeminiFreshness, now: Date): string { const start = new Date(now); start.setUTCDate(start.getUTCDate() - GEMINI_FRESHNESS_DAYS[freshness]); return toGeminiTimeRangeTimestamp(start); } function queryWithSoftFreshness(query: string, freshness?: "day"): string { if (freshness !== "day") { return query; } return `${query}\n\nSearch recency instruction: ${GEMINI_DAY_FRESHNESS_HINT} If no matching recent sources are available, state that limitation and use the most relevant available sources.`; } function resolveGeminiTimeRangeFilter( args: Record, now = new Date(), ): | { timeRangeFilter?: GeminiTimeRangeFilter; freshness?: "day" } | { error: | "invalid_freshness" | "invalid_date" | "invalid_date_range" | "conflicting_time_filters"; message: string; docs: string; } { const rawFreshness = readStringParam(args, "freshness"); const rawDateAfter = readStringParam(args, "date_after"); const rawDateBefore = readStringParam(args, "date_before"); const parsedTimeFilters = parseWebSearchTimeFilters({ rawDateAfter, rawDateBefore, rawFreshness, freshnessProvider: "perplexity", invalidFreshnessMessage: "freshness must be day, week, month, year, or the shortcuts pd, pw, pm, py.", invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.", invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.", invalidDateRangeMessage: "date_after must be before date_before.", }); if ("error" in parsedTimeFilters) { return parsedTimeFilters; } const { freshness, dateAfter, dateBefore } = parsedTimeFilters; if (freshness) { // Gemini rejects 24-hour google_search.timeRangeFilter windows, while // wider freshness windows still preserve the hard grounding contract. if (freshness === "day") { return { freshness, }; } return { timeRangeFilter: { startTime: freshnessStartTime(freshness, now), endTime: toGeminiTimeRangeTimestamp(now), }, }; } if (!dateAfter && !dateBefore) { return {}; } return { timeRangeFilter: { startTime: dateAfter ? isoDateStart(dateAfter) : "1970-01-01T00:00:00Z", endTime: dateBefore ? isoDateExclusiveEnd(dateBefore) : toGeminiTimeRangeTimestamp(now), }, }; } export function resolveGeminiRuntimeApiKey(gemini?: GeminiConfig): string | undefined { return ( readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ?? readProviderEnvValue(["GEMINI_API_KEY"]) ?? readConfiguredSecretString(gemini?.providerApiKey, "models.providers.google.apiKey") ); } async function runGeminiSearch(params: { query: string; apiKey: string; baseUrl: string; model: string; timeoutSeconds: number; signal?: AbortSignal; timeRangeFilter?: GeminiTimeRangeFilter; }): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { const endpoint = `${params.baseUrl}/models/${params.model}:generateContent`; const googleSearch = params.timeRangeFilter === undefined ? {} : { timeRangeFilter: params.timeRangeFilter }; return withTrustedWebSearchEndpoint( { url: endpoint, timeoutSeconds: params.timeoutSeconds, signal: params.signal, init: { method: "POST", headers: { "Content-Type": "application/json", "x-goog-api-key": params.apiKey, }, body: JSON.stringify({ contents: [{ parts: [{ text: params.query }] }], tools: [{ google_search: googleSearch }], }), }, }, async (res) => { if (!res.ok) { const error = await createProviderHttpError(res, "Gemini API error"); throw new Error(error.message.replace(/key=[^&\s]+/giu, "key=***")); } const data = (await readProviderJsonObjectResponse( res, "Gemini API error", )) as GeminiGroundingResponse; if (data.error) { const rawMessage = data.error.message || data.error.status || "unknown"; throw new Error( formatProviderHttpErrorMessage({ label: "Gemini API error", status: data.error.code ?? 0, detail: rawMessage.replace(/key=[^&\s]+/giu, "key=***"), }), ); } if (!Array.isArray(data.candidates)) { throwMalformedGeminiResponse(); } const candidate = data.candidates[0]; if (!isRecord(candidate) || !isRecord(candidate.content)) { throwMalformedGeminiResponse(); } const parts = candidate.content.parts; if (!Array.isArray(parts)) { throwMalformedGeminiResponse(); } const content = parts .map((part) => (isRecord(part) && typeof part.text === "string" ? part.text : undefined)) .filter((text): text is string => Boolean(text)) .join("\n"); if (!content) { throwMalformedGeminiResponse(); } const groundingMetadata = candidate.groundingMetadata; const groundingChunks = groundingMetadata === undefined ? [] : isRecord(groundingMetadata) ? groundingMetadata.groundingChunks === undefined ? [] : Array.isArray(groundingMetadata.groundingChunks) ? groundingMetadata.groundingChunks : undefined : undefined; if (!groundingChunks) { throwMalformedGeminiResponse(); } const rawCitations = groundingChunks.flatMap((chunk) => { if (!isRecord(chunk) || !isRecord(chunk.web) || typeof chunk.web.uri !== "string") { return []; } return [ { url: chunk.web.uri, title: typeof chunk.web.title === "string" ? chunk.web.title : undefined, }, ]; }); const citations: Array<{ url: string; title?: string }> = []; for (let index = 0; index < rawCitations.length; index += 10) { const batch = rawCitations.slice(index, index + 10); const resolved = await Promise.all( batch.map(async (citation) => Object.assign({}, citation, { url: await resolveCitationRedirectUrl(citation.url) }), ), ); citations.push(...resolved); } return { content, citations }; }, ); } export async function executeGeminiSearch( args: Record, searchConfig?: SearchConfigRecord, context?: { signal?: AbortSignal }, ): Promise> { const unsupportedResponse = buildUnsupportedSearchFilterResponse( { country: args.country, language: args.language, }, "gemini", ); if (unsupportedResponse) { return unsupportedResponse; } const timeRange = resolveGeminiTimeRangeFilter(args); if ("error" in timeRange) { return timeRange; } const geminiConfig = resolveGeminiConfig(searchConfig); const apiKey = resolveGeminiRuntimeApiKey(geminiConfig); if (!apiKey) { return { error: "missing_gemini_api_key", message: "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, configure plugins.entries.google.config.webSearch.apiKey, or reuse models.providers.google.apiKey. If you do not want to configure a search API key, use web_fetch for a specific URL or the browser tool for interactive pages.", docs: "https://docs.openclaw.ai/tools/web", }; } const query = readStringParam(args, "query", { required: true }); const count = readPositiveIntegerParam(args, "count", { max: MAX_SEARCH_COUNT, message: `count must be an integer from 1 to ${MAX_SEARCH_COUNT}.`, }) ?? searchConfig?.maxResults ?? undefined; const model = resolveGeminiModel(geminiConfig); const baseUrl = resolveGeminiBaseUrl(geminiConfig); const cacheKey = buildSearchCacheKey([ "gemini", query, resolveSearchCount(count, DEFAULT_SEARCH_COUNT), baseUrl, model, timeRange.freshness, timeRange.timeRangeFilter?.startTime, timeRange.timeRangeFilter?.endTime, ]); const cached = readCachedSearchPayload(cacheKey); if (cached) { return cached; } const start = Date.now(); const result = await runGeminiSearch({ query: queryWithSoftFreshness(query, timeRange.freshness), apiKey, baseUrl, model, timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), signal: context?.signal, timeRangeFilter: timeRange.timeRangeFilter, }); const payload = { query, provider: "gemini", model, tookMs: Date.now() - start, externalContent: { untrusted: true, source: "web_search", provider: "gemini", wrapped: true, }, content: wrapWebContent(result.content), citations: result.citations, }; writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); return payload; }