mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
fix(gemini): reuse google provider config for web search
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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?: (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ??= {});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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"
|
||||
>;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user