Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
375
extensions/google/src/gemini-web-search-provider.runtime.ts
Normal file
375
extensions/google/src/gemini-web-search-provider.runtime.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
// 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<GeminiFreshness, number> = {
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
searchConfig?: SearchConfigRecord,
|
||||
context?: { signal?: AbortSignal },
|
||||
): Promise<Record<string, unknown>> {
|
||||
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;
|
||||
}
|
||||
42
extensions/google/src/gemini-web-search-provider.shared.ts
Normal file
42
extensions/google/src/gemini-web-search-provider.shared.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Google provider module implements model/runtime integration.
|
||||
import {
|
||||
isRecord,
|
||||
normalizeOptionalString as trimToUndefined,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeGoogleApiBaseUrl } from "../provider-policy.js";
|
||||
|
||||
const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash";
|
||||
|
||||
export type GeminiConfig = {
|
||||
apiKey?: unknown;
|
||||
baseUrl?: unknown;
|
||||
model?: unknown;
|
||||
providerApiKey?: unknown;
|
||||
providerBaseUrl?: unknown;
|
||||
};
|
||||
|
||||
export function resolveGeminiConfig(searchConfig?: Record<string, unknown>): GeminiConfig {
|
||||
const gemini = searchConfig?.gemini;
|
||||
return isRecord(gemini) ? gemini : {};
|
||||
}
|
||||
|
||||
export function resolveGeminiApiKey(
|
||||
gemini?: GeminiConfig,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string | undefined {
|
||||
return (
|
||||
trimToUndefined(gemini?.apiKey) ??
|
||||
trimToUndefined(env.GEMINI_API_KEY) ??
|
||||
trimToUndefined(gemini?.providerApiKey)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveGeminiModel(gemini?: GeminiConfig): string {
|
||||
return trimToUndefined(gemini?.model) ?? DEFAULT_GEMINI_WEB_SEARCH_MODEL;
|
||||
}
|
||||
|
||||
export function resolveGeminiBaseUrl(gemini?: GeminiConfig): string {
|
||||
return normalizeGoogleApiBaseUrl(
|
||||
trimToUndefined(gemini?.baseUrl) ?? trimToUndefined(gemini?.providerBaseUrl),
|
||||
);
|
||||
}
|
||||
155
extensions/google/src/gemini-web-search-provider.ts
Normal file
155
extensions/google/src/gemini-web-search-provider.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// Google provider module implements model/runtime integration.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
mergeScopedSearchConfig,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
type WebSearchProviderPlugin,
|
||||
type WebSearchProviderToolDefinition,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
resolveGeminiApiKey,
|
||||
resolveGeminiBaseUrl,
|
||||
resolveGeminiModel,
|
||||
} from "./gemini-web-search-provider.shared.js";
|
||||
|
||||
const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey";
|
||||
const GOOGLE_PROVIDER_CREDENTIAL_PATH = "models.providers.google.apiKey";
|
||||
|
||||
const loadGeminiWebSearchRuntime = createLazyRuntimeModule(
|
||||
() => import("./gemini-web-search-provider.runtime.js"),
|
||||
);
|
||||
|
||||
const GEMINI_TOOL_PARAMETERS = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "integer",
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
},
|
||||
country: { type: "string", description: "Not supported by Gemini." },
|
||||
language: { type: "string", description: "Not supported by Gemini." },
|
||||
freshness: {
|
||||
type: "string",
|
||||
description:
|
||||
"Filter Gemini search freshness: week, month, and year use hard Google Search time ranges; day prioritizes the last 24 hours as a recency hint.",
|
||||
},
|
||||
date_after: {
|
||||
type: "string",
|
||||
description: "Only ground with results published after this date (YYYY-MM-DD).",
|
||||
},
|
||||
date_before: {
|
||||
type: "string",
|
||||
description: "Only ground with results published before this date (YYYY-MM-DD).",
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
function createGeminiToolDefinition(
|
||||
searchConfig?: Record<string, unknown>,
|
||||
): WebSearchProviderToolDefinition {
|
||||
return {
|
||||
description:
|
||||
"Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.",
|
||||
parameters: GEMINI_TOOL_PARAMETERS,
|
||||
execute: async (args, context) => {
|
||||
const { executeGeminiSearch } = await loadGeminiWebSearchRuntime();
|
||||
return await executeGeminiSearch(args, searchConfig, context);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGoogleModelProviderConfig(
|
||||
config?: OpenClawConfig,
|
||||
): Record<string, unknown> | undefined {
|
||||
const provider = config?.models?.providers?.google;
|
||||
return isRecord(provider) ? provider : undefined;
|
||||
}
|
||||
|
||||
function getGoogleModelProviderCredentialFallback(
|
||||
config?: OpenClawConfig,
|
||||
): { path: string; value: unknown } | undefined {
|
||||
const provider = resolveGoogleModelProviderConfig(config);
|
||||
return provider && provider.apiKey !== undefined
|
||||
? { path: GOOGLE_PROVIDER_CREDENTIAL_PATH, value: provider.apiKey }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function withGoogleModelProviderFallbacks(
|
||||
searchConfig: Record<string, unknown> | undefined,
|
||||
config?: OpenClawConfig,
|
||||
): Record<string, unknown> | undefined {
|
||||
const provider = resolveGoogleModelProviderConfig(config);
|
||||
if (!provider || (provider.apiKey === undefined && provider.baseUrl === undefined)) {
|
||||
return searchConfig;
|
||||
}
|
||||
const gemini = isRecord(searchConfig?.gemini) ? { ...searchConfig.gemini } : {};
|
||||
const mergedSearchConfig: Record<string, unknown> = searchConfig
|
||||
? Object.defineProperties({}, Object.getOwnPropertyDescriptors(searchConfig))
|
||||
: {};
|
||||
const geminiDescriptor = searchConfig
|
||||
? Object.getOwnPropertyDescriptor(searchConfig, "gemini")
|
||||
: undefined;
|
||||
if (provider.apiKey !== undefined) {
|
||||
gemini.providerApiKey = provider.apiKey;
|
||||
}
|
||||
if (provider.baseUrl !== undefined) {
|
||||
gemini.providerBaseUrl = provider.baseUrl;
|
||||
}
|
||||
Object.defineProperty(mergedSearchConfig, "gemini", {
|
||||
value: gemini,
|
||||
enumerable: geminiDescriptor?.enumerable ?? false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
return mergedSearchConfig;
|
||||
}
|
||||
|
||||
export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
const contractFields = createWebSearchProviderContractFields({
|
||||
credentialPath: GEMINI_CREDENTIAL_PATH,
|
||||
searchCredential: { type: "scoped", scopeId: "gemini" },
|
||||
configuredCredential: { pluginId: "google" },
|
||||
});
|
||||
|
||||
return {
|
||||
id: "gemini",
|
||||
label: "Gemini (Google Search)",
|
||||
hint: "Requires Google Gemini API key · Google Search grounding",
|
||||
onboardingScopes: ["text-inference"],
|
||||
credentialLabel: "Google Gemini API key",
|
||||
envVars: ["GEMINI_API_KEY"],
|
||||
placeholder: "AIza...",
|
||||
signupUrl: "https://aistudio.google.com/apikey",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 20,
|
||||
credentialPath: GEMINI_CREDENTIAL_PATH,
|
||||
...contractFields,
|
||||
getConfiguredCredentialFallback: getGoogleModelProviderCredentialFallback,
|
||||
createTool: (ctx) =>
|
||||
createGeminiToolDefinition(
|
||||
withGoogleModelProviderFallbacks(
|
||||
mergeScopedSearchConfig(
|
||||
ctx.searchConfig,
|
||||
"gemini",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "google"),
|
||||
),
|
||||
ctx.config,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
resolveGeminiApiKey,
|
||||
resolveGeminiBaseUrl,
|
||||
resolveGeminiModel,
|
||||
withGoogleModelProviderFallbacks,
|
||||
} as const;
|
||||
export { testing as __testing };
|
||||
Reference in New Issue
Block a user