refactor(web-search): share scoped provider config plumbing

This commit is contained in:
Vincent Koc
2026-03-19 23:50:25 -07:00
parent 96f21c37b4
commit a562fb5550
10 changed files with 136 additions and 183 deletions

View File

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

View File

@@ -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<string, unknown>): unknown {
const scoped = searchConfig?.firecrawl;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
return undefined;
}
return (scoped as Record<string, unknown>).apiKey;
}
function setScopedCredentialValue(
searchConfigTarget: Record<string, unknown>,
value: unknown,
): void {
const scoped = searchConfigTarget.firecrawl;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
searchConfigTarget.firecrawl = { apiKey: value };
return;
}
(scoped as Record<string, unknown>).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) => {

View File

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

View File

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

View File

@@ -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<string, unknown>).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<string, unknown>).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<string, unknown>
| undefined),
...(resolveProviderWebSearchPluginConfig(ctx.config, "perplexity") as
| Record<string, unknown>
| 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,
),
};

View File

@@ -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<string, unknown>): unknown {
const scoped = searchConfig?.tavily;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
return undefined;
}
return (scoped as Record<string, unknown>).apiKey;
}
function setScopedCredentialValue(
searchConfigTarget: Record<string, unknown>,
value: unknown,
): void {
const scoped = searchConfigTarget.tavily;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
searchConfigTarget.tavily = { apiKey: value };
return;
}
(scoped as Record<string, unknown>).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) => {

View File

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

View File

@@ -71,6 +71,37 @@ export function setScopedCredentialValue(
(scoped as Record<string, unknown>).apiKey = value;
}
export function mergeScopedSearchConfig(
searchConfig: Record<string, unknown> | undefined,
key: string,
pluginConfig: Record<string, unknown> | undefined,
options?: { mirrorApiKeyToTopLevel?: boolean },
): Record<string, unknown> | undefined {
if (!pluginConfig) {
return searchConfig;
}
const currentScoped =
searchConfig?.[key] &&
typeof searchConfig[key] === "object" &&
!Array.isArray(searchConfig[key])
? (searchConfig[key] as Record<string, unknown>)
: {};
const next: Record<string, unknown> = {
...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") {

View File

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

View File

@@ -30,6 +30,7 @@ export {
export {
getScopedCredentialValue,
getTopLevelCredentialValue,
mergeScopedSearchConfig,
resolveProviderWebSearchPluginConfig,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,