fix(gemini): reuse google provider config for web search

This commit is contained in:
Peter Steinberger
2026-05-02 05:14:51 +01:00
parent 7dc5b9484f
commit ed6df7dd8b
18 changed files with 504 additions and 48 deletions

View File

@@ -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.

View File

@@ -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.
<Tip>
Gemini 3 models use `thinkingLevel` rather than `thinkingBudget`. OpenClaw maps

View File

@@ -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.
</Step>
<Step title="Store the 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.

View File

@@ -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.<plugin>.config.webSearch.*`. See the provider pages for
examples.
`plugins.entries.<plugin>.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:

View File

@@ -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<T>(fn: () => Promise<T>): Promise<T> {
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);
});

View File

@@ -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",
};
}

View File

@@ -6,6 +6,8 @@ export type GeminiConfig = {
apiKey?: unknown;
baseUrl?: unknown;
model?: unknown;
providerApiKey?: unknown;
providerBaseUrl?: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -25,7 +27,11 @@ export function resolveGeminiApiKey(
gemini?: GeminiConfig,
env: Record<string, string | undefined> = 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),
);
}

View File

@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
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 } : {};
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,
),
),
};

View File

@@ -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<string, string>)["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<string, string>)["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"));

View File

@@ -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<string, unknown>;
@@ -87,6 +92,9 @@ export type WebSearchProviderPlugin = {
setCredentialValue: (searchConfigTarget: Record<string, unknown>, 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<OpenClawConfig>;
resolveRuntimeMetadata?: (

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -482,6 +482,14 @@ function readConfiguredProviderCredential(params: {
return params.provider.getConfiguredCredentialValue?.(params.config);
}
function readConfiguredProviderCredentialFallback(params: {
provider: PluginWebSearchProviderEntry;
config: OpenClawConfig;
search: Record<string, unknown> | 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,

View File

@@ -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<string, unknown> };
const entries = (plugins.entries ??= {});

View File

@@ -12,6 +12,7 @@ type CommonWebProviderTestParams = {
requiresCredential?: boolean;
getCredentialValue?: (config?: Record<string, unknown>) => 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,
};
}

View File

@@ -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 }) => ({

View File

@@ -76,6 +76,7 @@ function hasEntryCredential(
| "id"
| "envVars"
| "getConfiguredCredentialValue"
| "getConfiguredCredentialFallback"
| "getCredentialValue"
| "requiresCredential"
>,
@@ -88,6 +89,8 @@ function hasEntryCredential(
toolConfig: search as Record<string, unknown> | 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"
>;

View File

@@ -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,
);
}