From ed6df7dd8b95a08e84dfa56dcb7ce5b1619c0212 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 05:14:51 +0100 Subject: [PATCH] fix(gemini): reuse google provider config for web search --- CHANGELOG.md | 1 + docs/providers/google.md | 15 +- docs/tools/gemini-search.md | 23 ++-- docs/tools/web.md | 8 +- extensions/google/google.live.test.ts | 46 ++++++- .../src/gemini-web-search-provider.runtime.ts | 5 +- .../src/gemini-web-search-provider.shared.ts | 12 +- .../google/src/gemini-web-search-provider.ts | 67 +++++++-- extensions/google/web-search-provider.test.ts | 128 ++++++++++++++++++ src/plugins/web-provider-types.ts | 8 ++ src/secrets/runtime-web-tools.shared.ts | 65 +++++++-- src/secrets/runtime-web-tools.test.ts | 67 +++++++++ src/secrets/runtime-web-tools.ts | 20 +++ src/secrets/runtime.test-support.ts | 9 ++ .../web-provider-runtime.test-helpers.ts | 2 + src/web-search/runtime.test.ts | 40 ++++++ src/web-search/runtime.ts | 4 + src/web/provider-runtime-shared.ts | 32 ++++- 18 files changed, 504 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0e2fa5ff34..3b53c8d616e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars. - Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97. - Web search: late-bind managed agent `web_search` calls to the current runtime config snapshot, so existing sessions do not keep stale unresolved SecretRefs after secrets reload. Fixes #75420. Thanks @richardmqq. +- Web search/Gemini: reuse `models.providers.google.apiKey` and `models.providers.google.baseUrl` as lower-priority fallbacks for Gemini web search after dedicated search config and `GEMINI_API_KEY`. Supersedes #57496. Thanks @Aoiujz. - Web search/Gemini: pass `freshness` and `date_after`/`date_before` filters through Google Search grounding time ranges. Fixes #66498. Thanks @ismael-81. - Web search/DuckDuckGo: include the keyless DuckDuckGo provider in the web search setup wizard. Fixes #65862 and supersedes #65940. Thanks @Jah-yee. - Web search: honor `baseUrl` overrides for Gemini, Grok, and x_search provider-owned config, so proxy-backed search tools no longer dial hardcoded public endpoints. Supersedes #61972. Thanks @Lanfei. diff --git a/docs/providers/google.md b/docs/providers/google.md index fc5aea174b3..d30d83c30f9 100644 --- a/docs/providers/google.md +++ b/docs/providers/google.md @@ -147,7 +147,8 @@ Choose your preferred auth method and follow the setup steps. ## Web search The bundled `gemini` web-search provider uses Gemini Google Search grounding. -Configure it under `plugins.entries.google.config.webSearch`: +Configure a dedicated search key under `plugins.entries.google.config.webSearch`, +or let it reuse `models.providers.google.apiKey` after `GEMINI_API_KEY`: ```json5 { @@ -156,8 +157,8 @@ Configure it under `plugins.entries.google.config.webSearch`: google: { config: { webSearch: { - apiKey: "AIza...", // optional if GEMINI_API_KEY is set - baseUrl: "https://generativelanguage.googleapis.com/v1beta", + apiKey: "AIza...", // optional if GEMINI_API_KEY or models.providers.google.apiKey is set + baseUrl: "https://generativelanguage.googleapis.com/v1beta", // falls back to models.providers.google.baseUrl model: "gemini-2.5-flash", }, }, @@ -167,9 +168,11 @@ Configure it under `plugins.entries.google.config.webSearch`: } ``` -`webSearch.baseUrl` is optional and exists for operator proxies or compatible -Gemini API endpoints. See [Gemini search](/tools/gemini-search) for the -provider-specific tool behavior. +Credential precedence is dedicated `webSearch.apiKey`, then `GEMINI_API_KEY`, +then `models.providers.google.apiKey`. `webSearch.baseUrl` is optional and +exists for operator proxies or compatible Gemini API endpoints; when omitted, +Gemini web search reuses `models.providers.google.baseUrl`. See +[Gemini search](/tools/gemini-search) for the provider-specific tool behavior. Gemini 3 models use `thinkingLevel` rather than `thinkingBudget`. OpenClaw maps diff --git a/docs/tools/gemini-search.md b/docs/tools/gemini-search.md index e5e4c11c154..89e4e394c37 100644 --- a/docs/tools/gemini-search.md +++ b/docs/tools/gemini-search.md @@ -2,7 +2,7 @@ summary: "Gemini web search with Google Search grounding" read_when: - You want to use Gemini for web_search - - You need a GEMINI_API_KEY + - You need a GEMINI_API_KEY or models.providers.google.apiKey - You want Google Search grounding title: "Gemini search" --- @@ -20,7 +20,8 @@ citations. API key. - Set `GEMINI_API_KEY` in the Gateway environment, or configure via: + Set `GEMINI_API_KEY` in the Gateway environment, reuse + `models.providers.google.apiKey`, or configure a dedicated web-search key via: ```bash openclaw configure --section web @@ -38,8 +39,8 @@ citations. google: { config: { webSearch: { - apiKey: "AIza...", // optional if GEMINI_API_KEY is set - baseUrl: "https://generativelanguage.googleapis.com/v1beta", // optional proxy/base URL override + apiKey: "AIza...", // optional if GEMINI_API_KEY or models.providers.google.apiKey is set + baseUrl: "https://generativelanguage.googleapis.com/v1beta", // optional; falls back to models.providers.google.baseUrl model: "gemini-2.5-flash", // default }, }, @@ -56,8 +57,13 @@ citations. } ``` -**Environment alternative:** set `GEMINI_API_KEY` in the Gateway environment. -For a gateway install, put it in `~/.openclaw/.env`. +**Credential precedence:** Gemini web search uses +`plugins.entries.google.config.webSearch.apiKey` first, then `GEMINI_API_KEY`, +then `models.providers.google.apiKey`. For base URLs, the dedicated +`plugins.entries.google.config.webSearch.baseUrl` wins before +`models.providers.google.baseUrl`. + +For a gateway install, put env keys in `~/.openclaw/.env`. ## How it works @@ -95,8 +101,9 @@ model that supports grounding can be used via ## Base URL overrides Set `plugins.entries.google.config.webSearch.baseUrl` when Gemini web search -must route through an operator proxy or custom Gemini-compatible endpoint. A -plain `https://generativelanguage.googleapis.com` value is normalized to +must route through an operator proxy or custom Gemini-compatible endpoint. If +that is unset, Gemini web search reuses `models.providers.google.baseUrl`. A plain +`https://generativelanguage.googleapis.com` value is normalized to `https://generativelanguage.googleapis.com/v1beta`; custom proxy paths are kept as provided after trimming trailing slashes. diff --git a/docs/tools/web.md b/docs/tools/web.md index 746d7748909..17c83006e71 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -165,7 +165,7 @@ API-backed providers first: 1. **Brave** -- `BRAVE_API_KEY` or `plugins.entries.brave.config.webSearch.apiKey` (order 10) 2. **MiniMax Search** -- `MINIMAX_CODE_PLAN_KEY` / `MINIMAX_CODING_API_KEY` or `plugins.entries.minimax.config.webSearch.apiKey` (order 15) -3. **Gemini** -- `GEMINI_API_KEY` or `plugins.entries.google.config.webSearch.apiKey` (order 20) +3. **Gemini** -- `plugins.entries.google.config.webSearch.apiKey`, `GEMINI_API_KEY`, or `models.providers.google.apiKey` (order 20) 4. **Grok** -- `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey` (order 30) 5. **Kimi** -- `KIMI_API_KEY` / `MOONSHOT_API_KEY` or `plugins.entries.moonshot.config.webSearch.apiKey` (order 40) 6. **Perplexity** -- `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` or `plugins.entries.perplexity.config.webSearch.apiKey` (order 50) @@ -213,8 +213,10 @@ error prompting you to configure one). ``` Provider-specific config (API keys, base URLs, modes) lives under -`plugins.entries..config.webSearch.*`. See the provider pages for -examples. +`plugins.entries..config.webSearch.*`. Gemini can also reuse +`models.providers.google.apiKey` and `models.providers.google.baseUrl` as lower-priority +fallbacks after its dedicated web-search config and `GEMINI_API_KEY`. See the +provider pages for examples. `web_fetch` fallback provider selection is separate: diff --git a/extensions/google/google.live.test.ts b/extensions/google/google.live.test.ts index d8ef01b399e..23805291cb4 100644 --- a/extensions/google/google.live.test.ts +++ b/extensions/google/google.live.test.ts @@ -9,10 +9,26 @@ import plugin from "./index.js"; import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js"; const GOOGLE_API_KEY = - process.env.GEMINI_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || ""; + process.env.GEMINI_API_KEY?.trim() || + process.env.GOOGLE_API_KEY?.trim() || + process.env.GEMINI_PROVIDER_API_KEY?.trim() || + ""; const LIVE = isLiveTestEnabled() && GOOGLE_API_KEY.length > 0; const describeLive = LIVE ? describe : describe.skip; +async function withGoogleApiEnvUnset(fn: () => Promise): Promise { + const geminiApiKey = process.env.GEMINI_API_KEY; + const googleApiKey = process.env.GOOGLE_API_KEY; + delete process.env.GEMINI_API_KEY; + delete process.env.GOOGLE_API_KEY; + try { + return await fn(); + } finally { + process.env.GEMINI_API_KEY = geminiApiKey; + process.env.GOOGLE_API_KEY = googleApiKey; + } +} + function isTransientGeminiSearchError(error: unknown): boolean { if (!(error instanceof Error)) { return false; @@ -124,4 +140,32 @@ describeLive("google plugin live", () => { expect((result?.content as string).length).toBeGreaterThan(20); expect(Array.isArray(result?.citations)).toBe(true); }, 120_000); + + it("runs Gemini web search through the Google model provider config fallback", async () => { + await withGoogleApiEnvUnset(async () => { + const provider = createGeminiWebSearchProvider(); + const tool = provider.createTool?.({ + config: { + models: { + providers: { + google: { + apiKey: GOOGLE_API_KEY, + }, + }, + }, + }, + searchConfig: { provider: "gemini", cacheTtlMinutes: 0, timeoutSeconds: 90 }, + } as never); + + const result = await tool?.execute({ query: "OpenClaw GitHub", count: 1 }); + + expect(process.env.GEMINI_API_KEY).toBeUndefined(); + expect(process.env.GOOGLE_API_KEY).toBeUndefined(); + expect(result?.provider).toBe("gemini"); + expect(typeof result?.content).toBe("string"); + expect((result?.content as string).length).toBeGreaterThan(20); + expect(Array.isArray(result?.citations)).toBe(true); + expect((result?.citations as unknown[]).length).toBeGreaterThan(0); + }); + }, 120_000); }); diff --git a/extensions/google/src/gemini-web-search-provider.runtime.ts b/extensions/google/src/gemini-web-search-provider.runtime.ts index b7b6516f06a..5f8d8a2993e 100644 --- a/extensions/google/src/gemini-web-search-provider.runtime.ts +++ b/extensions/google/src/gemini-web-search-provider.runtime.ts @@ -155,7 +155,8 @@ function resolveGeminiTimeRangeFilter( export function resolveGeminiRuntimeApiKey(gemini?: GeminiConfig): string | undefined { return ( readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ?? - readProviderEnvValue(["GEMINI_API_KEY"]) + readProviderEnvValue(["GEMINI_API_KEY"]) ?? + readConfiguredSecretString(gemini?.providerApiKey, "models.providers.google.apiKey") ); } @@ -267,7 +268,7 @@ export async function executeGeminiSearch( return { error: "missing_gemini_api_key", message: - "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.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.", + "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", }; } diff --git a/extensions/google/src/gemini-web-search-provider.shared.ts b/extensions/google/src/gemini-web-search-provider.shared.ts index eeed0414c52..70804062d59 100644 --- a/extensions/google/src/gemini-web-search-provider.shared.ts +++ b/extensions/google/src/gemini-web-search-provider.shared.ts @@ -6,6 +6,8 @@ export type GeminiConfig = { apiKey?: unknown; baseUrl?: unknown; model?: unknown; + providerApiKey?: unknown; + providerBaseUrl?: unknown; }; function isRecord(value: unknown): value is Record { @@ -25,7 +27,11 @@ export function resolveGeminiApiKey( gemini?: GeminiConfig, env: Record = process.env, ): string | undefined { - return trimToUndefined(gemini?.apiKey) ?? trimToUndefined(env.GEMINI_API_KEY); + return ( + trimToUndefined(gemini?.apiKey) ?? + trimToUndefined(env.GEMINI_API_KEY) ?? + trimToUndefined(gemini?.providerApiKey) + ); } export function resolveGeminiModel(gemini?: GeminiConfig): string { @@ -33,5 +39,7 @@ export function resolveGeminiModel(gemini?: GeminiConfig): string { } export function resolveGeminiBaseUrl(gemini?: GeminiConfig): string { - return normalizeGoogleApiBaseUrl(trimToUndefined(gemini?.baseUrl)); + return normalizeGoogleApiBaseUrl( + trimToUndefined(gemini?.baseUrl) ?? trimToUndefined(gemini?.providerBaseUrl), + ); } diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index a028acb7eab..fb3a3f1625d 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -1,3 +1,4 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { createWebSearchProviderContractFields, mergeScopedSearchConfig, @@ -12,6 +13,7 @@ import { } 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"; type GeminiWebSearchRuntime = typeof import("./gemini-web-search-provider.runtime.js"); @@ -64,7 +66,54 @@ function createGeminiToolDefinition( }; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function resolveGoogleModelProviderConfig( + config?: OpenClawConfig, +): Record | 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 | undefined, + config?: OpenClawConfig, +): Record | undefined { + const provider = resolveGoogleModelProviderConfig(config); + if (!provider || (provider.apiKey === undefined && provider.baseUrl === undefined)) { + return searchConfig; + } + const gemini = isRecord(searchConfig?.gemini) ? { ...searchConfig.gemini } : {}; + if (provider.apiKey !== undefined) { + gemini.providerApiKey = provider.apiKey; + } + if (provider.baseUrl !== undefined) { + gemini.providerBaseUrl = provider.baseUrl; + } + return { + ...(searchConfig ?? {}), + gemini, + }; +} + 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)", @@ -77,17 +126,17 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin { docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 20, credentialPath: GEMINI_CREDENTIAL_PATH, - ...createWebSearchProviderContractFields({ - credentialPath: GEMINI_CREDENTIAL_PATH, - searchCredential: { type: "scoped", scopeId: "gemini" }, - configuredCredential: { pluginId: "google" }, - }), + ...contractFields, + getConfiguredCredentialFallback: getGoogleModelProviderCredentialFallback, createTool: (ctx) => createGeminiToolDefinition( - mergeScopedSearchConfig( - ctx.searchConfig, - "gemini", - resolveProviderWebSearchPluginConfig(ctx.config, "google"), + withGoogleModelProviderFallbacks( + mergeScopedSearchConfig( + ctx.searchConfig, + "gemini", + resolveProviderWebSearchPluginConfig(ctx.config, "google"), + ), + ctx.config, ), ), }; diff --git a/extensions/google/web-search-provider.test.ts b/extensions/google/web-search-provider.test.ts index 7ce7493321e..20c1cce950a 100644 --- a/extensions/google/web-search-provider.test.ts +++ b/extensions/google/web-search-provider.test.ts @@ -59,6 +59,14 @@ describe("google web search provider", () => { }); }); + it("uses provider api keys only after env fallbacks", () => { + withEnv({ GEMINI_API_KEY: "AIza-env-test" }, () => { + expect(__testing.resolveGeminiApiKey({ providerApiKey: "AIza-provider-test" })).toBe( + "AIza-env-test", + ); + }); + }); + it("stores configured credentials at the canonical plugin config path", () => { const provider = createGeminiWebSearchProvider(); const config = {} as OpenClawConfig; @@ -102,6 +110,126 @@ describe("google web search provider", () => { ); }); + it("reuses the Google model provider key when no web search key or env key is set", async () => { + await withEnvAsync({ GEMINI_API_KEY: undefined }, async () => { + const mockFetch = installGeminiFetch(); + const provider = createGeminiWebSearchProvider(); + const tool = provider.createTool({ + config: { + models: { + providers: { + google: { + apiKey: "AIza-provider-test", + }, + }, + }, + }, + searchConfig: { provider: "gemini" }, + }); + + await tool?.execute({ query: "OpenClaw provider key fallback" }); + + expect( + (mockFetch.mock.calls[0]?.[1]?.headers as Record)["x-goog-api-key"], + ).toBe("AIza-provider-test"); + }); + }); + + it("keeps plugin web search keys ahead of env and provider keys", async () => { + await withEnvAsync({ GEMINI_API_KEY: "AIza-env-test" }, async () => { + const mockFetch = installGeminiFetch(); + const provider = createGeminiWebSearchProvider(); + const tool = provider.createTool({ + config: { + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: "AIza-plugin-test", + }, + }, + }, + }, + }, + models: { + providers: { + google: { + apiKey: "AIza-provider-test", + }, + }, + }, + }, + searchConfig: { provider: "gemini" }, + }); + + await tool?.execute({ query: "OpenClaw plugin key precedence" }); + + expect( + (mockFetch.mock.calls[0]?.[1]?.headers as Record)["x-goog-api-key"], + ).toBe("AIza-plugin-test"); + }); + }); + + it("routes Gemini web search through provider-level google.baseUrl as a fallback", async () => { + const mockFetch = installGeminiFetch(); + const provider = createGeminiWebSearchProvider(); + const tool = provider.createTool({ + config: { + models: { + providers: { + google: { + apiKey: "AIza-provider-test", + baseUrl: "https://generativelanguage.googleapis.com/provider/v1beta/", + }, + }, + }, + }, + searchConfig: { provider: "gemini" }, + }); + + await tool?.execute({ query: "OpenClaw provider baseUrl fallback" }); + + expect(String(mockFetch.mock.calls[0]?.[0])).toBe( + "https://generativelanguage.googleapis.com/provider/v1beta/models/gemini-2.5-flash:generateContent", + ); + }); + + it("keeps plugin webSearch.baseUrl ahead of provider-level google.baseUrl", async () => { + const mockFetch = installGeminiFetch(); + const provider = createGeminiWebSearchProvider(); + const tool = provider.createTool({ + config: { + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: "AIza-plugin-test", + baseUrl: "https://generativelanguage.googleapis.com/plugin/v1beta/", + }, + }, + }, + }, + }, + models: { + providers: { + google: { + baseUrl: "https://generativelanguage.googleapis.com/provider/v1beta/", + }, + }, + }, + }, + searchConfig: { provider: "gemini" }, + }); + + await tool?.execute({ query: "OpenClaw plugin baseUrl precedence" }); + + expect(String(mockFetch.mock.calls[0]?.[0])).toBe( + "https://generativelanguage.googleapis.com/plugin/v1beta/models/gemini-2.5-flash:generateContent", + ); + }); + it("passes freshness to Gemini Google Search grounding as a time range", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-15T12:00:00Z")); diff --git a/src/plugins/web-provider-types.ts b/src/plugins/web-provider-types.ts index e2e9edccad6..01a6d0fb327 100644 --- a/src/plugins/web-provider-types.ts +++ b/src/plugins/web-provider-types.ts @@ -37,6 +37,11 @@ export type WebFetchProviderContext = { export type WebSearchCredentialResolutionSource = "config" | "secretRef" | "env" | "missing"; +export type WebSearchProviderConfiguredCredentialFallback = { + path: string; + value: unknown; +}; + export type WebSearchRuntimeMetadataContext = { config?: OpenClawConfig; searchConfig?: Record; @@ -87,6 +92,9 @@ export type WebSearchProviderPlugin = { setCredentialValue: (searchConfigTarget: Record, value: unknown) => void; getConfiguredCredentialValue?: (config?: OpenClawConfig) => unknown; setConfiguredCredentialValue?: (configTarget: OpenClawConfig, value: unknown) => void; + getConfiguredCredentialFallback?: ( + config?: OpenClawConfig, + ) => WebSearchProviderConfiguredCredentialFallback | undefined; applySelectionConfig?: (config: OpenClawConfig) => OpenClawConfig; runSetup?: (ctx: WebSearchProviderSetupContext) => OpenClawConfig | Promise; resolveRuntimeMetadata?: ( diff --git a/src/secrets/runtime-web-tools.shared.ts b/src/secrets/runtime-web-tools.shared.ts index da308303c5a..0932ff3dee8 100644 --- a/src/secrets/runtime-web-tools.shared.ts +++ b/src/secrets/runtime-web-tools.shared.ts @@ -64,6 +64,11 @@ export type RuntimeWebProviderSelectionParams< config: OpenClawConfig; toolConfig: TToolConfig; }) => unknown; + readConfiguredCredentialFallback?: (params: { + provider: TProvider; + config: OpenClawConfig; + toolConfig: TToolConfig; + }) => { path: string; value: unknown } | undefined; resolveSecretInput: (params: { value: unknown; path: string; @@ -253,7 +258,12 @@ export async function resolveRuntimeWebProviderSurface< provider, config: params.sourceConfig, toolConfig: params.toolConfig, - }) !== undefined + }) !== undefined || + params.readConfiguredCredentialFallback?.({ + provider, + config: params.sourceConfig, + toolConfig: params.toolConfig, + })?.value !== undefined ); }); const providers = @@ -339,52 +349,77 @@ export async function resolveRuntimeWebProviderSelection< path, envVars: "envVars" in provider && Array.isArray(provider.envVars) ? provider.envVars : [], }); + let selectedCandidatePath = path; + let selectedCandidateResolution = resolution; - if (resolution.secretRefConfigured && resolution.fallbackUsedAfterRefFailure) { + if (!resolution.value && !resolution.secretRefConfigured) { + const fallback = params.readConfiguredCredentialFallback?.({ + provider, + config: params.sourceConfig, + toolConfig: params.toolConfig, + }); + if (fallback?.value !== undefined) { + selectedCandidatePath = fallback.path; + selectedCandidateResolution = await params.resolveSecretInput({ + value: fallback.value, + path: fallback.path, + envVars: [], + }); + } + } + + if ( + selectedCandidateResolution.secretRefConfigured && + selectedCandidateResolution.fallbackUsedAfterRefFailure + ) { const diagnostic: RuntimeWebDiagnostic = { code: params.fallbackUsedCode, message: - `${path} SecretRef could not be resolved; using ${resolution.fallbackEnvVar ?? "env fallback"}. ` + - (resolution.unresolvedRefReason ?? "").trim(), - path, + `${selectedCandidatePath} SecretRef could not be resolved; using ${selectedCandidateResolution.fallbackEnvVar ?? "env fallback"}. ` + + (selectedCandidateResolution.unresolvedRefReason ?? "").trim(), + path: selectedCandidatePath, }; params.diagnostics.push(diagnostic); params.metadata.diagnostics.push(diagnostic); pushWarning(params.context, { code: params.fallbackUsedCode, - path, + path: selectedCandidatePath, message: diagnostic.message, }); } - if (resolution.secretRefConfigured && !resolution.value && resolution.unresolvedRefReason) { + if ( + selectedCandidateResolution.secretRefConfigured && + !selectedCandidateResolution.value && + selectedCandidateResolution.unresolvedRefReason + ) { unresolvedWithoutFallback.push({ provider: provider.id, - path, - reason: resolution.unresolvedRefReason, + path: selectedCandidatePath, + reason: selectedCandidateResolution.unresolvedRefReason, }); } if (params.configuredProvider) { selectedProvider = provider.id; - selectedResolution = resolution; - if (resolution.value) { + selectedResolution = selectedCandidateResolution; + if (selectedCandidateResolution.value) { params.setResolvedCredential({ resolvedConfig: params.resolvedConfig, provider, - value: resolution.value, + value: selectedCandidateResolution.value, }); } break; } - if (resolution.value) { + if (selectedCandidateResolution.value) { selectedProvider = provider.id; - selectedResolution = resolution; + selectedResolution = selectedCandidateResolution; params.setResolvedCredential({ resolvedConfig: params.resolvedConfig, provider, - value: resolution.value, + value: selectedCandidateResolution.value, }); break; } diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index 95b43cdce13..1870ee357b5 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -195,6 +195,18 @@ function createTestProvider(params: { ? (entryConfig as { webSearch?: { apiKey?: unknown } }).webSearch?.apiKey : undefined; }, + getConfiguredCredentialFallback: + params.provider === "gemini" + ? (config) => { + const provider = config?.models?.providers?.google; + return provider && typeof provider === "object" && "apiKey" in provider + ? { + path: "models.providers.google.apiKey", + value: (provider as { apiKey?: unknown }).apiKey, + } + : undefined; + } + : undefined, setConfiguredCredentialValue: (configTarget, value) => { setConfiguredProviderKey(configTarget, params.pluginId, value); }, @@ -791,6 +803,61 @@ describe("runtime web tools resolution", () => { ); }); + it("auto-detects Gemini from the Google model provider key after env fallbacks", async () => { + const { metadata, resolvedConfig } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + search: { + enabled: true, + }, + }, + }, + models: { + providers: { + google: { + apiKey: "google-provider-runtime-key", + }, + }, + }, + }), + }); + + expect(metadata.search.providerSource).toBe("auto-detect"); + expect(metadata.search.selectedProvider).toBe("gemini"); + expect(metadata.search.selectedProviderKeySource).toBe("config"); + expect(readProviderKey(resolvedConfig, "gemini")).toBe("google-provider-runtime-key"); + }); + + it("prefers GEMINI_API_KEY over the Google model provider key", async () => { + const { metadata, resolvedConfig } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + search: { + enabled: true, + }, + }, + }, + models: { + providers: { + google: { + apiKey: "google-provider-runtime-key", + }, + }, + }, + }), + env: { + GEMINI_API_KEY: "gemini-env-runtime-key", + }, + }); + + expect(metadata.search.providerSource).toBe("auto-detect"); + expect(metadata.search.selectedProvider).toBe("gemini"); + expect(metadata.search.selectedProviderKeySource).toBe("env"); + expect(readProviderKey(resolvedConfig, "gemini")).toBe("gemini-env-runtime-key"); + }); + it("warns when provider is invalid and falls back to auto-detect", async () => { const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ config: asConfig({ diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 1b24859c9be..43509849a0a 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -482,6 +482,14 @@ function readConfiguredProviderCredential(params: { return params.provider.getConfiguredCredentialValue?.(params.config); } +function readConfiguredProviderCredentialFallback(params: { + provider: PluginWebSearchProviderEntry; + config: OpenClawConfig; + search: Record | undefined; +}): { path: string; value: unknown } | undefined { + return params.provider.getConfiguredCredentialFallback?.(params.config); +} + function inactivePathsForProvider(provider: PluginWebSearchProviderEntry): string[] { if (provider.requiresCredential === false) { return []; @@ -657,6 +665,12 @@ export async function resolveRuntimeWebTools(params: { config, search: toolConfig, }), + readConfiguredCredentialFallback: ({ provider, config, toolConfig }) => + readConfiguredProviderCredentialFallback({ + provider, + config, + search: toolConfig, + }), ignoreKeylessProvidersForConfiguredSurface: true, emptyProvidersWhenSurfaceMissing: true, normalizeConfiguredProviderAgainstActiveProviders: true, @@ -684,6 +698,12 @@ export async function resolveRuntimeWebTools(params: { config, search: toolConfig, }), + readConfiguredCredentialFallback: ({ provider, config, toolConfig }) => + readConfiguredProviderCredentialFallback({ + provider, + config, + search: toolConfig, + }), resolveSecretInput: ({ value, path, envVars }) => resolveSecretInputWithEnvFallback({ sourceConfig: params.sourceConfig, diff --git a/src/secrets/runtime.test-support.ts b/src/secrets/runtime.test-support.ts index a54456ac3f4..a0ac9a4059a 100644 --- a/src/secrets/runtime.test-support.ts +++ b/src/secrets/runtime.test-support.ts @@ -68,6 +68,15 @@ function createTestProvider(params: { getConfiguredCredentialValue: (config) => (config?.plugins?.entries?.[params.pluginId]?.config as { webSearch?: { apiKey?: unknown } }) ?.webSearch?.apiKey, + getConfiguredCredentialFallback: + params.id === "gemini" + ? (config) => { + const provider = (config?.models?.providers?.google ?? {}) as { apiKey?: unknown }; + return provider.apiKey !== undefined + ? { path: "models.providers.google.apiKey", value: provider.apiKey } + : undefined; + } + : undefined, setConfiguredCredentialValue: (configTarget, value) => { const plugins = (configTarget.plugins ??= {}) as { entries?: Record }; const entries = (plugins.entries ??= {}); diff --git a/src/test-utils/web-provider-runtime.test-helpers.ts b/src/test-utils/web-provider-runtime.test-helpers.ts index 465779a0225..7116b3c2d52 100644 --- a/src/test-utils/web-provider-runtime.test-helpers.ts +++ b/src/test-utils/web-provider-runtime.test-helpers.ts @@ -12,6 +12,7 @@ type CommonWebProviderTestParams = { requiresCredential?: boolean; getCredentialValue?: (config?: Record) => unknown; getConfiguredCredentialValue?: (config?: OpenClawConfig) => unknown; + getConfiguredCredentialFallback?: PluginWebSearchProviderEntry["getConfiguredCredentialFallback"]; }; export type WebSearchTestProviderParams = CommonWebProviderTestParams & { @@ -37,6 +38,7 @@ function createCommonProviderFields(params: CommonWebProviderTestParams) { getCredentialValue: params.getCredentialValue ?? (() => undefined), setCredentialValue: () => {}, getConfiguredCredentialValue: params.getConfiguredCredentialValue, + getConfiguredCredentialFallback: params.getConfiguredCredentialFallback, }; } diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index 2c70d23e2a0..aae87c34220 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -155,6 +155,46 @@ describe("web search runtime", () => { }); }); + it("auto-detects a provider from a configured credential fallback", async () => { + const provider = createCustomSearchProvider({ + getConfiguredCredentialFallback: (config) => { + const modelProvider = config?.models?.providers?.["custom-search"]; + return modelProvider && typeof modelProvider === "object" && "apiKey" in modelProvider + ? { + path: "models.providers.custom-search.apiKey", + value: (modelProvider as { apiKey?: unknown }).apiKey, + } + : undefined; + }, + }); + resolveRuntimeWebSearchProvidersMock.mockReturnValue([ + provider, + createDuckDuckGoSearchProvider(), + ]); + resolvePluginWebSearchProvidersMock.mockReturnValue([ + provider, + createDuckDuckGoSearchProvider(), + ]); + + await expect( + runWebSearch({ + config: { + models: { + providers: { + "custom-search": { + apiKey: "custom-provider-key", + }, + }, + }, + }, + args: { query: "fallback" }, + }), + ).resolves.toEqual({ + provider: "custom", + result: { query: "fallback", ok: true }, + }); + }); + it("uses the active resolved runtime config for matching source config callers", async () => { const provider = createCustomSearchProvider({ createTool: ({ config }) => ({ diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index ec5a219571e..806c16cb29e 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -76,6 +76,7 @@ function hasEntryCredential( | "id" | "envVars" | "getConfiguredCredentialValue" + | "getConfiguredCredentialFallback" | "getCredentialValue" | "requiresCredential" >, @@ -88,6 +89,8 @@ function hasEntryCredential( toolConfig: search as Record | undefined, resolveRawValue: ({ provider: currentProvider, config: currentConfig }) => currentProvider.getConfiguredCredentialValue?.(currentConfig), + resolveFallbackRawValue: ({ provider: currentProvider, config: currentConfig }) => + currentProvider.getConfiguredCredentialFallback?.(currentConfig)?.value, resolveEnvValue: ({ provider: currentProvider, configuredEnvVarId }) => (configuredEnvVarId ? readWebProviderEnvValue([configuredEnvVarId]) : undefined) ?? readWebProviderEnvValue(currentProvider.envVars), @@ -101,6 +104,7 @@ export function isWebSearchProviderConfigured(params: { | "id" | "envVars" | "getConfiguredCredentialValue" + | "getConfiguredCredentialFallback" | "getCredentialValue" | "requiresCredential" >; diff --git a/src/web/provider-runtime-shared.ts b/src/web/provider-runtime-shared.ts index d43db5c5aae..73d06a40278 100644 --- a/src/web/provider-runtime-shared.ts +++ b/src/web/provider-runtime-shared.ts @@ -58,6 +58,11 @@ export function hasWebProviderEntryCredential< config: OpenClawConfig | undefined; toolConfig: TConfig; }) => unknown; + resolveFallbackRawValue?: (params: { + provider: TProvider; + config: OpenClawConfig | undefined; + toolConfig: TConfig; + }) => unknown; resolveEnvValue: (params: { provider: TProvider; configuredEnvVarId?: string; @@ -81,11 +86,34 @@ export function hasWebProviderEntryCredential< if (fromConfig) { return true; } - return Boolean( + if ( params.resolveEnvValue({ provider: params.provider, configuredEnvVarId: configuredRef?.source === "env" ? configuredRef.id : undefined, - }), + }) + ) { + return true; + } + const fallbackRawValue = params.resolveFallbackRawValue?.({ + provider: params.provider, + config: params.config, + toolConfig: params.toolConfig, + }); + const fallbackRef = resolveSecretInputRef({ value: fallbackRawValue }).ref; + if (fallbackRef && fallbackRef.source !== "env") { + return true; + } + const fallbackConfig = normalizeSecretInput(normalizeSecretInputString(fallbackRawValue)); + if (fallbackConfig) { + return true; + } + return Boolean( + fallbackRef?.source === "env" + ? params.resolveEnvValue({ + provider: params.provider, + configuredEnvVarId: fallbackRef.id, + }) + : undefined, ); }