diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index 4e68d5a2803..50decf4d59d 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -4,6 +4,7 @@ import { DEFAULT_SEARCH_COUNT, MAX_SEARCH_COUNT, formatCliCommand, + mergeScopedSearchConfig, normalizeFreshness, normalizeToIsoDate, readCachedSearchPayload, @@ -607,21 +608,12 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin { }, createTool: (ctx) => createBraveToolDefinition( - (() => { - const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined; - const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "brave"); - if (!pluginConfig) { - return searchConfig; - } - return { - ...(searchConfig ?? {}), - ...(pluginConfig.apiKey === undefined ? {} : { apiKey: pluginConfig.apiKey }), - brave: { - ...resolveBraveConfig(searchConfig), - ...pluginConfig, - }, - } as SearchConfigRecord; - })(), + mergeScopedSearchConfig( + ctx.searchConfig as SearchConfigRecord | undefined, + "brave", + resolveProviderWebSearchPluginConfig(ctx.config, "brave"), + { mirrorApiKeyToTopLevel: true }, + ) as SearchConfigRecord | undefined, ), }; } diff --git a/extensions/firecrawl/src/firecrawl-search-provider.ts b/extensions/firecrawl/src/firecrawl-search-provider.ts index 11a0fa0788d..f91ae5f26d9 100644 --- a/extensions/firecrawl/src/firecrawl-search-provider.ts +++ b/extensions/firecrawl/src/firecrawl-search-provider.ts @@ -1,7 +1,9 @@ import { Type } from "@sinclair/typebox"; import { enablePluginInConfig, + getScopedCredentialValue, resolveProviderWebSearchPluginConfig, + setScopedCredentialValue, setProviderWebSearchPluginConfigValue, type WebSearchProviderPlugin, } from "openclaw/plugin-sdk/provider-web-search"; @@ -21,26 +23,6 @@ const GenericFirecrawlSearchSchema = Type.Object( { additionalProperties: false }, ); -function getScopedCredentialValue(searchConfig?: Record): unknown { - const scoped = searchConfig?.firecrawl; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - return undefined; - } - return (scoped as Record).apiKey; -} - -function setScopedCredentialValue( - searchConfigTarget: Record, - value: unknown, -): void { - const scoped = searchConfigTarget.firecrawl; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - searchConfigTarget.firecrawl = { apiKey: value }; - return; - } - (scoped as Record).apiKey = value; -} - export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin { return { id: "firecrawl", @@ -53,8 +35,9 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin { autoDetectOrder: 60, credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"], - getCredentialValue: getScopedCredentialValue, - setCredentialValue: setScopedCredentialValue, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "firecrawl"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "firecrawl", value), getConfiguredCredentialValue: (config) => resolveProviderWebSearchPluginConfig(config, "firecrawl")?.apiKey, setConfiguredCredentialValue: (configTarget, value) => { diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index b5878da55e6..c316896953c 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -3,7 +3,9 @@ import { buildSearchCacheKey, buildUnsupportedSearchFilterResponse, DEFAULT_SEARCH_COUNT, + getScopedCredentialValue, MAX_SEARCH_COUNT, + mergeScopedSearchConfig, readCachedSearchPayload, readConfiguredSecretString, readNumberParam, @@ -14,6 +16,7 @@ import { resolveSearchCacheTtlMs, resolveSearchCount, resolveSearchTimeoutSeconds, + setScopedCredentialValue, setProviderWebSearchPluginConfigValue, type SearchConfigRecord, type WebSearchProviderPlugin, @@ -250,20 +253,9 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin { autoDetectOrder: 20, credentialPath: "plugins.entries.google.config.webSearch.apiKey", inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => { - const gemini = searchConfig?.gemini; - return gemini && typeof gemini === "object" && !Array.isArray(gemini) - ? (gemini as Record).apiKey - : undefined; - }, - setCredentialValue: (searchConfigTarget, value) => { - const scoped = searchConfigTarget.gemini; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - searchConfigTarget.gemini = { apiKey: value }; - return; - } - (scoped as Record).apiKey = value; - }, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "gemini", value), getConfiguredCredentialValue: (config) => resolveProviderWebSearchPluginConfig(config, "google")?.apiKey, setConfiguredCredentialValue: (configTarget, value) => { @@ -271,20 +263,11 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin { }, createTool: (ctx) => createGeminiToolDefinition( - (() => { - const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined; - const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "google"); - if (!pluginConfig) { - return searchConfig; - } - return { - ...(searchConfig ?? {}), - gemini: { - ...resolveGeminiConfig(searchConfig), - ...pluginConfig, - }, - } as SearchConfigRecord; - })(), + mergeScopedSearchConfig( + ctx.searchConfig as SearchConfigRecord | undefined, + "gemini", + resolveProviderWebSearchPluginConfig(ctx.config, "google"), + ) as SearchConfigRecord | undefined, ), }; } diff --git a/extensions/moonshot/src/kimi-web-search-provider.ts b/extensions/moonshot/src/kimi-web-search-provider.ts index 33f0f7e11cd..cca3bcf7aa8 100644 --- a/extensions/moonshot/src/kimi-web-search-provider.ts +++ b/extensions/moonshot/src/kimi-web-search-provider.ts @@ -3,7 +3,9 @@ import { buildSearchCacheKey, buildUnsupportedSearchFilterResponse, DEFAULT_SEARCH_COUNT, + getScopedCredentialValue, MAX_SEARCH_COUNT, + mergeScopedSearchConfig, readCachedSearchPayload, readConfiguredSecretString, readNumberParam, @@ -13,6 +15,7 @@ import { resolveSearchCacheTtlMs, resolveSearchCount, resolveSearchTimeoutSeconds, + setScopedCredentialValue, setProviderWebSearchPluginConfigValue, type SearchConfigRecord, type WebSearchProviderPlugin, @@ -322,20 +325,9 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin { autoDetectOrder: 40, credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey", inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => { - const kimi = searchConfig?.kimi; - return kimi && typeof kimi === "object" && !Array.isArray(kimi) - ? (kimi as Record).apiKey - : undefined; - }, - setCredentialValue: (searchConfigTarget, value) => { - const scoped = searchConfigTarget.kimi; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - searchConfigTarget.kimi = { apiKey: value }; - return; - } - (scoped as Record).apiKey = value; - }, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "kimi", value), getConfiguredCredentialValue: (config) => resolveProviderWebSearchPluginConfig(config, "moonshot")?.apiKey, setConfiguredCredentialValue: (configTarget, value) => { @@ -343,20 +335,11 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin { }, createTool: (ctx) => createKimiToolDefinition( - (() => { - const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined; - const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"); - if (!pluginConfig) { - return searchConfig; - } - return { - ...(searchConfig ?? {}), - kimi: { - ...resolveKimiConfig(searchConfig), - ...pluginConfig, - }, - } as SearchConfigRecord; - })(), + mergeScopedSearchConfig( + ctx.searchConfig as SearchConfigRecord | undefined, + "kimi", + resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"), + ) as SearchConfigRecord | undefined, ), }; } diff --git a/extensions/perplexity/src/perplexity-web-search-provider.ts b/extensions/perplexity/src/perplexity-web-search-provider.ts index a7b4b12e94c..6fba3b4b03f 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.ts @@ -7,8 +7,10 @@ import { import { buildSearchCacheKey, DEFAULT_SEARCH_COUNT, + getScopedCredentialValue, MAX_SEARCH_COUNT, isoToPerplexityDate, + mergeScopedSearchConfig, normalizeFreshness, normalizeToIsoDate, readCachedSearchPayload, @@ -19,6 +21,7 @@ import { resolveSearchCount, resolveSearchTimeoutSeconds, resolveSiteName, + setScopedCredentialValue, setProviderWebSearchPluginConfigValue, throwWebSearchApiError, type SearchConfigRecord, @@ -658,20 +661,9 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { autoDetectOrder: 50, credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey", inactiveSecretPaths: ["plugins.entries.perplexity.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => { - const perplexity = searchConfig?.perplexity; - return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity) - ? (perplexity as Record).apiKey - : undefined; - }, - setCredentialValue: (searchConfigTarget, value) => { - const scoped = searchConfigTarget.perplexity; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - searchConfigTarget.perplexity = { apiKey: value }; - return; - } - (scoped as Record).apiKey = value; - }, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "perplexity", value), getConfiguredCredentialValue: (config) => resolveProviderWebSearchPluginConfig(config, "perplexity")?.apiKey, setConfiguredCredentialValue: (configTarget, value) => { @@ -679,17 +671,11 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { }, resolveRuntimeMetadata: (ctx) => ({ perplexityTransport: resolveRuntimeTransport({ - searchConfig: { - ...(ctx.searchConfig as SearchConfigRecord | undefined), - perplexity: { - ...((ctx.searchConfig as SearchConfigRecord | undefined)?.perplexity as - | Record - | undefined), - ...(resolveProviderWebSearchPluginConfig(ctx.config, "perplexity") as - | Record - | undefined), - }, - }, + searchConfig: mergeScopedSearchConfig( + ctx.searchConfig as SearchConfigRecord | undefined, + "perplexity", + resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"), + ) as SearchConfigRecord | undefined, resolvedKey: ctx.resolvedCredential?.value, keySource: ctx.resolvedCredential?.source ?? "missing", fallbackEnvVar: ctx.resolvedCredential?.fallbackEnvVar, @@ -697,20 +683,11 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { }), createTool: (ctx) => createPerplexityToolDefinition( - (() => { - const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined; - const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"); - if (!pluginConfig) { - return searchConfig; - } - return { - ...(searchConfig ?? {}), - perplexity: { - ...resolvePerplexityConfig(searchConfig), - ...pluginConfig, - }, - } as SearchConfigRecord; - })(), + mergeScopedSearchConfig( + ctx.searchConfig as SearchConfigRecord | undefined, + "perplexity", + resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"), + ) as SearchConfigRecord | undefined, ctx.runtimeMetadata?.perplexityTransport as PerplexityTransport | undefined, ), }; diff --git a/extensions/tavily/src/tavily-search-provider.ts b/extensions/tavily/src/tavily-search-provider.ts index 2ad33362353..4ed5fedd783 100644 --- a/extensions/tavily/src/tavily-search-provider.ts +++ b/extensions/tavily/src/tavily-search-provider.ts @@ -1,7 +1,9 @@ import { Type } from "@sinclair/typebox"; import { enablePluginInConfig, + getScopedCredentialValue, resolveProviderWebSearchPluginConfig, + setScopedCredentialValue, setProviderWebSearchPluginConfigValue, type WebSearchProviderPlugin, } from "openclaw/plugin-sdk/provider-web-search"; @@ -21,26 +23,6 @@ const GenericTavilySearchSchema = Type.Object( { additionalProperties: false }, ); -function getScopedCredentialValue(searchConfig?: Record): unknown { - const scoped = searchConfig?.tavily; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - return undefined; - } - return (scoped as Record).apiKey; -} - -function setScopedCredentialValue( - searchConfigTarget: Record, - value: unknown, -): void { - const scoped = searchConfigTarget.tavily; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - searchConfigTarget.tavily = { apiKey: value }; - return; - } - (scoped as Record).apiKey = value; -} - export function createTavilyWebSearchProvider(): WebSearchProviderPlugin { return { id: "tavily", @@ -53,8 +35,9 @@ export function createTavilyWebSearchProvider(): WebSearchProviderPlugin { autoDetectOrder: 70, credentialPath: "plugins.entries.tavily.config.webSearch.apiKey", inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"], - getCredentialValue: getScopedCredentialValue, - setCredentialValue: setScopedCredentialValue, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "tavily"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "tavily", value), getConfiguredCredentialValue: (config) => resolveProviderWebSearchPluginConfig(config, "tavily")?.apiKey, setConfiguredCredentialValue: (configTarget, value) => { diff --git a/extensions/xai/src/grok-web-search-provider.ts b/extensions/xai/src/grok-web-search-provider.ts index bc38abb6444..d9a6f0f8d46 100644 --- a/extensions/xai/src/grok-web-search-provider.ts +++ b/extensions/xai/src/grok-web-search-provider.ts @@ -3,7 +3,9 @@ import { buildSearchCacheKey, buildUnsupportedSearchFilterResponse, DEFAULT_SEARCH_COUNT, + getScopedCredentialValue, MAX_SEARCH_COUNT, + mergeScopedSearchConfig, readCachedSearchPayload, readConfiguredSecretString, readNumberParam, @@ -13,6 +15,7 @@ import { resolveSearchCacheTtlMs, resolveSearchCount, resolveSearchTimeoutSeconds, + setScopedCredentialValue, setProviderWebSearchPluginConfigValue, type SearchConfigRecord, type WebSearchProviderPlugin, @@ -265,20 +268,9 @@ export function createGrokWebSearchProvider(): WebSearchProviderPlugin { autoDetectOrder: 30, credentialPath: "plugins.entries.xai.config.webSearch.apiKey", inactiveSecretPaths: ["plugins.entries.xai.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => { - const grok = searchConfig?.grok; - return grok && typeof grok === "object" && !Array.isArray(grok) - ? (grok as Record).apiKey - : undefined; - }, - setCredentialValue: (searchConfigTarget, value) => { - const scoped = searchConfigTarget.grok; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - searchConfigTarget.grok = { apiKey: value }; - return; - } - (scoped as Record).apiKey = value; - }, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "grok"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "grok", value), getConfiguredCredentialValue: (config) => resolveProviderWebSearchPluginConfig(config, "xai")?.apiKey, setConfiguredCredentialValue: (configTarget, value) => { @@ -286,20 +278,11 @@ export function createGrokWebSearchProvider(): WebSearchProviderPlugin { }, createTool: (ctx) => createGrokToolDefinition( - (() => { - const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined; - const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "xai"); - if (!pluginConfig) { - return searchConfig; - } - return { - ...(searchConfig ?? {}), - grok: { - ...resolveGrokConfig(searchConfig), - ...pluginConfig, - }, - } as SearchConfigRecord; - })(), + mergeScopedSearchConfig( + ctx.searchConfig as SearchConfigRecord | undefined, + "grok", + resolveProviderWebSearchPluginConfig(ctx.config, "xai"), + ) as SearchConfigRecord | undefined, ), }; } diff --git a/src/agents/tools/web-search-provider-config.ts b/src/agents/tools/web-search-provider-config.ts index 3e246b93068..dd938957b12 100644 --- a/src/agents/tools/web-search-provider-config.ts +++ b/src/agents/tools/web-search-provider-config.ts @@ -71,6 +71,37 @@ export function setScopedCredentialValue( (scoped as Record).apiKey = value; } +export function mergeScopedSearchConfig( + searchConfig: Record | undefined, + key: string, + pluginConfig: Record | undefined, + options?: { mirrorApiKeyToTopLevel?: boolean }, +): Record | undefined { + if (!pluginConfig) { + return searchConfig; + } + + const currentScoped = + searchConfig?.[key] && + typeof searchConfig[key] === "object" && + !Array.isArray(searchConfig[key]) + ? (searchConfig[key] as Record) + : {}; + const next: Record = { + ...searchConfig, + [key]: { + ...currentScoped, + ...pluginConfig, + }, + }; + + if (options?.mirrorApiKeyToTopLevel && pluginConfig.apiKey !== undefined) { + next.apiKey = pluginConfig.apiKey; + } + + return next; +} + export function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { const search = cfg?.tools?.web?.search; if (!search || typeof search !== "object") { diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index ae7b517c788..9f3a6fe017c 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -3,7 +3,10 @@ import { __testing as braveTesting } from "../../../extensions/brave/src/brave-w import { __testing as moonshotTesting } from "../../../extensions/moonshot/src/kimi-web-search-provider.js"; import { __testing as perplexityTesting } from "../../../extensions/perplexity/web-search-provider.js"; import { __testing as xaiTesting } from "../../../extensions/xai/src/grok-web-search-provider.js"; -import { buildUnsupportedSearchFilterResponse } from "../../plugin-sdk/provider-web-search.js"; +import { + buildUnsupportedSearchFilterResponse, + mergeScopedSearchConfig, +} from "../../plugin-sdk/provider-web-search.js"; import { withEnv } from "../../test-utils/env.js"; const { inferPerplexityBaseUrlFromApiKey, @@ -223,6 +226,40 @@ describe("web_search unsupported filter response", () => { }); }); +describe("web_search scoped config merge", () => { + it("returns the original config when no plugin config exists", () => { + const searchConfig = { provider: "grok", grok: { model: "grok-4-1-fast" } }; + expect(mergeScopedSearchConfig(searchConfig, "grok", undefined)).toBe(searchConfig); + }); + + it("merges plugin config into the scoped provider object", () => { + expect( + mergeScopedSearchConfig({ provider: "grok", grok: { model: "old-model" } }, "grok", { + model: "new-model", + apiKey: "xai-test-key", + }), + ).toEqual({ + provider: "grok", + grok: { model: "new-model", apiKey: "xai-test-key" }, + }); + }); + + it("can mirror the plugin apiKey to the top level config", () => { + expect( + mergeScopedSearchConfig( + { provider: "brave", brave: { count: 5 } }, + "brave", + { apiKey: "brave-test-key" }, + { mirrorApiKeyToTopLevel: true }, + ), + ).toEqual({ + provider: "brave", + apiKey: "brave-test-key", + brave: { count: 5, apiKey: "brave-test-key" }, + }); + }); +}); + describe("web_search kimi config resolution", () => { it("uses config apiKey when provided", () => { expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key"); diff --git a/src/plugin-sdk/provider-web-search.ts b/src/plugin-sdk/provider-web-search.ts index 78c2fff4ce3..258d26e7ee4 100644 --- a/src/plugin-sdk/provider-web-search.ts +++ b/src/plugin-sdk/provider-web-search.ts @@ -30,6 +30,7 @@ export { export { getScopedCredentialValue, getTopLevelCredentialValue, + mergeScopedSearchConfig, resolveProviderWebSearchPluginConfig, setScopedCredentialValue, setProviderWebSearchPluginConfigValue,