refactor web search config ownership into extensions

This commit is contained in:
Tak Hoffman
2026-03-17 23:29:52 -05:00
committed by Val Alexander
parent 84cf8c32aa
commit a03f43d5bd
29 changed files with 856 additions and 304 deletions

View File

@@ -1,8 +1,34 @@
{
"id": "brave",
"uiHints": {
"webSearch.apiKey": {
"label": "Brave Search API Key",
"help": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
"sensitive": true,
"placeholder": "BSA..."
},
"webSearch.mode": {
"label": "Brave Search Mode",
"help": "Brave Search mode: web or llm-context."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"mode": {
"type": "string",
"enum": ["web", "llm-context"]
}
}
}
}
}
}

View File

@@ -17,7 +17,12 @@ import {
withTrustedWebSearchEndpoint,
writeCachedSearchPayload,
} from "../../../src/agents/tools/web-search-provider-common.js";
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
} from "../../../src/agents/tools/web-search-provider-config.js";
import { formatCliCommand } from "../../../src/cli/command-format.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type {
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
@@ -90,6 +95,7 @@ const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
type BraveConfig = {
apiKey?: unknown;
mode?: string;
};
@@ -112,18 +118,41 @@ type BraveLlmContextResponse = {
sources?: { url?: string; hostname?: string; date?: string }[];
};
function resolveBraveConfig(searchConfig?: SearchConfigRecord): BraveConfig {
const brave = searchConfig?.brave;
return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {};
function resolveBraveConfig(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): BraveConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "brave");
if (pluginConfig) {
return pluginConfig as BraveConfig;
}
const scoped = (searchConfig as Record<string, unknown> | undefined)?.brave;
return scoped && typeof scoped === "object" && !Array.isArray(scoped)
? ({
...(scoped as BraveConfig),
apiKey: (searchConfig as Record<string, unknown> | undefined)?.apiKey,
} as BraveConfig)
: ({ apiKey: (searchConfig as Record<string, unknown> | undefined)?.apiKey } as BraveConfig);
}
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
return brave.mode === "llm-context" ? "llm-context" : "web";
}
function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
function resolveBraveApiKey(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): string | undefined {
const braveConfig = resolveBraveConfig(config, searchConfig);
return (
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
readConfiguredSecretString(
braveConfig.apiKey,
"plugins.entries.brave.config.webSearch.apiKey",
) ??
readConfiguredSecretString(
(searchConfig as Record<string, unknown> | undefined)?.apiKey,
"tools.web.search.apiKey",
) ??
readProviderEnvValue(["BRAVE_API_KEY"])
);
}
@@ -384,9 +413,10 @@ function missingBraveKeyPayload() {
}
function createBraveToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
const braveConfig = resolveBraveConfig(searchConfig);
const braveConfig = resolveBraveConfig(config, searchConfig);
const braveMode = resolveBraveMode(braveConfig);
return {
@@ -396,7 +426,7 @@ function createBraveToolDefinition(
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
parameters: createBraveSchema(),
execute: async (args) => {
const apiKey = resolveBraveApiKey(searchConfig);
const apiKey = resolveBraveApiKey(config, searchConfig);
if (!apiKey) {
return missingBraveKeyPayload();
}
@@ -594,14 +624,19 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://brave.com/search/api/",
docsUrl: "https://docs.openclaw.ai/brave-search",
autoDetectOrder: 10,
credentialPath: "tools.web.search.apiKey",
inactiveSecretPaths: ["tools.web.search.apiKey"],
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
setCredentialValue: (searchConfigTarget, value) => {
searchConfigTarget.apiKey = value;
},
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value);
},
createTool: (ctx) =>
createBraveToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
createBraveToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
};
}

View File

@@ -1,8 +1,33 @@
{
"id": "firecrawl",
"uiHints": {
"webSearch.apiKey": {
"label": "Firecrawl Search API Key",
"help": "Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).",
"sensitive": true,
"placeholder": "fc-..."
},
"webSearch.baseUrl": {
"label": "Firecrawl Search Base URL",
"help": "Firecrawl Search base URL override."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"baseUrl": {
"type": "string"
}
}
}
}
}
}

View File

@@ -26,6 +26,15 @@ type FirecrawlSearchConfig =
}
| undefined;
type PluginEntryConfig =
| {
webSearch?: {
apiKey?: unknown;
baseUrl?: string;
};
}
| undefined;
type FirecrawlFetchConfig =
| {
apiKey?: unknown;
@@ -53,6 +62,11 @@ function resolveFetchConfig(cfg?: OpenClawConfig): WebFetchConfig {
}
export function resolveFirecrawlSearchConfig(cfg?: OpenClawConfig): FirecrawlSearchConfig {
const pluginConfig = cfg?.plugins?.entries?.firecrawl?.config as PluginEntryConfig;
const pluginWebSearch = pluginConfig?.webSearch;
if (pluginWebSearch && typeof pluginWebSearch === "object" && !Array.isArray(pluginWebSearch)) {
return pluginWebSearch;
}
const search = resolveSearchConfig(cfg);
if (!search || typeof search !== "object") {
return undefined;
@@ -89,6 +103,10 @@ export function resolveFirecrawlApiKey(cfg?: OpenClawConfig): string | undefined
const search = resolveFirecrawlSearchConfig(cfg);
const fetch = resolveFirecrawlFetchConfig(cfg);
return (
normalizeConfiguredSecret(
search?.apiKey,
"plugins.entries.firecrawl.config.webSearch.apiKey",
) ||
normalizeConfiguredSecret(search?.apiKey, "tools.web.search.firecrawl.apiKey") ||
normalizeConfiguredSecret(fetch?.apiKey, "tools.web.fetch.firecrawl.apiKey") ||
normalizeSecretInput(process.env.FIRECRAWL_API_KEY) ||

View File

@@ -1,4 +1,8 @@
import { Type } from "@sinclair/typebox";
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
} from "../../../src/agents/tools/web-search-provider-config.js";
import { enablePluginInConfig } from "../../../src/plugins/enable.js";
import type { WebSearchProviderPlugin } from "../../../src/plugins/types.js";
import { runFirecrawlSearch } from "./firecrawl-client.js";
@@ -47,10 +51,15 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://www.firecrawl.dev/",
docsUrl: "https://docs.openclaw.ai/tools/firecrawl",
autoDetectOrder: 60,
credentialPath: "tools.web.search.firecrawl.apiKey",
inactiveSecretPaths: ["tools.web.search.firecrawl.apiKey"],
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"],
getCredentialValue: getScopedCredentialValue,
setCredentialValue: setScopedCredentialValue,
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "firecrawl")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "firecrawl", "apiKey", value);
},
applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config,
createTool: (ctx) => ({
description:

View File

@@ -29,9 +29,34 @@
"groupHint": "Gemini API key + OAuth"
}
],
"uiHints": {
"webSearch.apiKey": {
"label": "Gemini Search API Key",
"help": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).",
"sensitive": true,
"placeholder": "AIza..."
},
"webSearch.model": {
"label": "Gemini Search Model",
"help": "Gemini model override for web search grounding."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"model": {
"type": "string"
}
}
}
}
}
}

View File

@@ -15,6 +15,11 @@ import {
withTrustedWebSearchEndpoint,
writeCachedSearchPayload,
} from "../../../src/agents/tools/web-search-provider-common.js";
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
} from "../../../src/agents/tools/web-search-provider-config.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type {
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
@@ -52,8 +57,15 @@ type GeminiGroundingResponse = {
};
};
function resolveGeminiConfig(searchConfig?: SearchConfigRecord): GeminiConfig {
const gemini = searchConfig?.gemini;
function resolveGeminiConfig(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): GeminiConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "google");
if (pluginConfig) {
return pluginConfig as GeminiConfig;
}
const gemini = (searchConfig as Record<string, unknown> | undefined)?.gemini;
return gemini && typeof gemini === "object" && !Array.isArray(gemini)
? (gemini as GeminiConfig)
: {};
@@ -61,7 +73,7 @@ function resolveGeminiConfig(searchConfig?: SearchConfigRecord): GeminiConfig {
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
return (
readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ??
readConfiguredSecretString(gemini?.apiKey, "plugins.entries.google.config.webSearch.apiKey") ??
readProviderEnvValue(["GEMINI_API_KEY"])
);
}
@@ -168,6 +180,7 @@ function createGeminiSchema() {
}
function createGeminiToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
return {
@@ -194,13 +207,13 @@ function createGeminiToolDefinition(
}
}
const geminiConfig = resolveGeminiConfig(searchConfig);
const geminiConfig = resolveGeminiConfig(config, searchConfig);
const apiKey = resolveGeminiApiKey(geminiConfig);
if (!apiKey) {
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.",
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure plugins.entries.google.config.webSearch.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@@ -259,8 +272,8 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://aistudio.google.com/apikey",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 20,
credentialPath: "tools.web.search.gemini.apiKey",
inactiveSecretPaths: ["tools.web.search.gemini.apiKey"],
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)
@@ -275,8 +288,13 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
}
(scoped as Record<string, unknown>).apiKey = value;
},
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "google")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "google", "apiKey", value);
},
createTool: (ctx) =>
createGeminiToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
createGeminiToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
};
}

View File

@@ -32,9 +32,40 @@
"cliDescription": "Moonshot API key"
}
],
"uiHints": {
"webSearch.apiKey": {
"label": "Kimi Search API Key",
"help": "Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).",
"sensitive": true
},
"webSearch.baseUrl": {
"label": "Kimi Search Base URL",
"help": "Kimi base URL override."
},
"webSearch.model": {
"label": "Kimi Search Model",
"help": "Kimi model override."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"baseUrl": {
"type": "string"
},
"model": {
"type": "string"
}
}
}
}
}
}

View File

@@ -14,6 +14,11 @@ import {
withTrustedWebSearchEndpoint,
writeCachedSearchPayload,
} from "../../../src/agents/tools/web-search-provider-common.js";
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
} from "../../../src/agents/tools/web-search-provider-config.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type {
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
@@ -61,14 +66,18 @@ type KimiSearchResponse = {
}>;
};
function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig {
const kimi = searchConfig?.kimi;
function resolveKimiConfig(config?: OpenClawConfig, searchConfig?: SearchConfigRecord): KimiConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "moonshot");
if (pluginConfig) {
return pluginConfig as KimiConfig;
}
const kimi = (searchConfig as Record<string, unknown> | undefined)?.kimi;
return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {};
}
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
return (
readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ??
readConfiguredSecretString(kimi?.apiKey, "plugins.entries.moonshot.config.webSearch.apiKey") ??
readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"])
);
}
@@ -237,6 +246,7 @@ function createKimiSchema() {
}
function createKimiToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
return {
@@ -263,13 +273,13 @@ function createKimiToolDefinition(
}
}
const kimiConfig = resolveKimiConfig(searchConfig);
const kimiConfig = resolveKimiConfig(config, searchConfig);
const apiKey = resolveKimiApiKey(kimiConfig);
if (!apiKey) {
return {
error: "missing_kimi_api_key",
message:
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.",
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure plugins.entries.moonshot.config.webSearch.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@@ -331,8 +341,8 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://platform.moonshot.cn/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 40,
credentialPath: "tools.web.search.kimi.apiKey",
inactiveSecretPaths: ["tools.web.search.kimi.apiKey"],
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)
@@ -347,8 +357,13 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
}
(scoped as Record<string, unknown>).apiKey = value;
},
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "moonshot")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "moonshot", "apiKey", value);
},
createTool: (ctx) =>
createKimiToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
createKimiToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
};
}

View File

@@ -1,8 +1,40 @@
{
"id": "perplexity",
"uiHints": {
"webSearch.apiKey": {
"label": "Perplexity API Key",
"help": "Perplexity or OpenRouter API key for web search.",
"sensitive": true,
"placeholder": "pplx-..."
},
"webSearch.baseUrl": {
"label": "Perplexity Base URL",
"help": "Optional Perplexity/OpenRouter chat-completions base URL override."
},
"webSearch.model": {
"label": "Perplexity Model",
"help": "Optional Sonar/OpenRouter model override."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"baseUrl": {
"type": "string"
},
"model": {
"type": "string"
}
}
}
}
}
}

View File

@@ -23,6 +23,11 @@ import {
withTrustedWebSearchEndpoint,
writeCachedSearchPayload,
} from "../../../src/agents/tools/web-search-provider-common.js";
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
} from "../../../src/agents/tools/web-search-provider-config.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type {
WebSearchCredentialResolutionSource,
WebSearchProviderPlugin,
@@ -71,8 +76,15 @@ type PerplexitySearchApiResponse = {
}>;
};
function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig {
const perplexity = searchConfig?.perplexity;
function resolvePerplexityConfig(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): PerplexityConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "perplexity");
if (pluginConfig) {
return pluginConfig as PerplexityConfig;
}
const perplexity = (searchConfig as Record<string, unknown> | undefined)?.perplexity;
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as PerplexityConfig)
: {};
@@ -98,7 +110,7 @@ function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
} {
const fromConfig = readConfiguredSecretString(
perplexity?.apiKey,
"tools.web.search.perplexity.apiKey",
"plugins.entries.perplexity.config.webSearch.apiKey",
);
if (fromConfig) {
return { apiKey: fromConfig, source: "config" };
@@ -313,16 +325,16 @@ async function runPerplexitySearch(params: {
}
function resolveRuntimeTransport(params: {
config?: OpenClawConfig;
searchConfig?: Record<string, unknown>;
resolvedKey?: string;
keySource: WebSearchCredentialResolutionSource;
fallbackEnvVar?: string;
}): PerplexityTransport | undefined {
const perplexity = params.searchConfig?.perplexity;
const scoped =
perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as { baseUrl?: string; model?: string })
: undefined;
const scoped = resolvePerplexityConfig(
params.config,
params.searchConfig as SearchConfigRecord | undefined,
);
const configuredBaseUrl = typeof scoped?.baseUrl === "string" ? scoped.baseUrl.trim() : "";
const configuredModel = typeof scoped?.model === "string" ? scoped.model.trim() : "";
const baseUrl = (() => {
@@ -404,10 +416,11 @@ function createPerplexitySchema(transport?: PerplexityTransport) {
}
function createPerplexityToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
runtimeTransport?: PerplexityTransport,
): WebSearchProviderToolDefinition {
const perplexityConfig = resolvePerplexityConfig(searchConfig);
const perplexityConfig = resolvePerplexityConfig(config, searchConfig);
const schemaTransport =
runtimeTransport ??
(perplexityConfig.baseUrl || perplexityConfig.model ? "chat_completions" : undefined);
@@ -424,7 +437,7 @@ function createPerplexityToolDefinition(
return {
error: "missing_perplexity_api_key",
message:
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure plugins.entries.perplexity.config.webSearch.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@@ -656,8 +669,8 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://www.perplexity.ai/settings/api",
docsUrl: "https://docs.openclaw.ai/perplexity",
autoDetectOrder: 50,
credentialPath: "tools.web.search.perplexity.apiKey",
inactiveSecretPaths: ["tools.web.search.perplexity.apiKey"],
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)
@@ -672,8 +685,14 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
}
(scoped as Record<string, unknown>).apiKey = value;
},
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "perplexity")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "perplexity", "apiKey", value);
},
resolveRuntimeMetadata: (ctx) => ({
perplexityTransport: resolveRuntimeTransport({
config: ctx.config,
searchConfig: ctx.searchConfig,
resolvedKey: ctx.resolvedCredential?.value,
keySource: ctx.resolvedCredential?.source ?? "missing",
@@ -682,6 +701,7 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
}),
createTool: (ctx) =>
createPerplexityToolDefinition(
ctx.config,
ctx.searchConfig as SearchConfigRecord | undefined,
ctx.runtimeMetadata?.perplexityTransport as PerplexityTransport | undefined,
),

View File

@@ -19,9 +19,40 @@
"cliDescription": "xAI API key"
}
],
"uiHints": {
"webSearch.apiKey": {
"label": "Grok Search API Key",
"help": "xAI API key for Grok web search (fallback: XAI_API_KEY env var).",
"sensitive": true
},
"webSearch.model": {
"label": "Grok Search Model",
"help": "Grok model override for web search."
},
"webSearch.inlineCitations": {
"label": "Inline Citations",
"help": "Include inline markdown citations in Grok responses."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"model": {
"type": "string"
},
"inlineCitations": {
"type": "boolean"
}
}
}
}
}
}

View File

@@ -14,6 +14,11 @@ import {
withTrustedWebSearchEndpoint,
writeCachedSearchPayload,
} from "../../../src/agents/tools/web-search-provider-common.js";
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
} from "../../../src/agents/tools/web-search-provider-config.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type {
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
@@ -60,14 +65,18 @@ type GrokSearchResponse = {
}>;
};
function resolveGrokConfig(searchConfig?: SearchConfigRecord): GrokConfig {
const grok = searchConfig?.grok;
function resolveGrokConfig(config?: OpenClawConfig, searchConfig?: SearchConfigRecord): GrokConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "xai");
if (pluginConfig) {
return pluginConfig as GrokConfig;
}
const grok = (searchConfig as Record<string, unknown> | undefined)?.grok;
return grok && typeof grok === "object" && !Array.isArray(grok) ? (grok as GrokConfig) : {};
}
function resolveGrokApiKey(grok?: GrokConfig): string | undefined {
return (
readConfiguredSecretString(grok?.apiKey, "tools.web.search.grok.apiKey") ??
readConfiguredSecretString(grok?.apiKey, "plugins.entries.xai.config.webSearch.apiKey") ??
readProviderEnvValue(["XAI_API_KEY"])
);
}
@@ -179,6 +188,7 @@ function createGrokSchema() {
}
function createGrokToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
return {
@@ -205,13 +215,13 @@ function createGrokToolDefinition(
}
}
const grokConfig = resolveGrokConfig(searchConfig);
const grokConfig = resolveGrokConfig(config, searchConfig);
const apiKey = resolveGrokApiKey(grokConfig);
if (!apiKey) {
return {
error: "missing_xai_api_key",
message:
"web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.",
"web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure plugins.entries.xai.config.webSearch.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@@ -274,8 +284,8 @@ export function createGrokWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://console.x.ai/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 30,
credentialPath: "tools.web.search.grok.apiKey",
inactiveSecretPaths: ["tools.web.search.grok.apiKey"],
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)
@@ -290,8 +300,13 @@ export function createGrokWebSearchProvider(): WebSearchProviderPlugin {
}
(scoped as Record<string, unknown>).apiKey = value;
},
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "xai")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "xai", "apiKey", value);
},
createTool: (ctx) =>
createGrokToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
createGrokToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
};
}

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../../config/config.js";
import { resolvePluginWebSearchConfig } from "../../config/legacy-web-search.js";
type ConfiguredWebSearchProvider = NonNullable<
NonNullable<NonNullable<OpenClawConfig["tools"]>["web"]>["search"]
@@ -78,6 +79,40 @@ export function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig {
return search as WebSearchConfig;
}
export function resolveProviderWebSearchPluginConfig(
config: OpenClawConfig | undefined,
pluginId: string,
): Record<string, unknown> | undefined {
return resolvePluginWebSearchConfig(config, pluginId);
}
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
const current = target[key];
if (current && typeof current === "object" && !Array.isArray(current)) {
return current as Record<string, unknown>;
}
const next: Record<string, unknown> = {};
target[key] = next;
return next;
}
export function setProviderWebSearchPluginConfigValue(
configTarget: OpenClawConfig,
pluginId: string,
key: string,
value: unknown,
): void {
const plugins = ensureObject(configTarget as Record<string, unknown>, "plugins");
const entries = ensureObject(plugins, "entries");
const entry = ensureObject(entries, pluginId);
if (entry.enabled === undefined) {
entry.enabled = true;
}
const config = ensureObject(entry, "config");
const webSearch = ensureObject(config, "webSearch");
webSearch[key] = value;
}
export function resolveSearchEnabled(params: {
search?: WebSearchConfig;
sandboxed?: boolean;

View File

@@ -44,12 +44,14 @@ export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
}
function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown {
const search = config.tools?.web?.search;
const entry = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
return entry?.getCredentialValue(search as Record<string, unknown> | undefined);
return (
entry?.getConfiguredCredentialValue?.(config) ??
entry?.getCredentialValue(config.tools?.web?.search as Record<string, unknown> | undefined)
);
}
/** Returns the plaintext key string, or undefined for SecretRefs/missing. */
@@ -99,17 +101,24 @@ export function applySearchKey(
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
const search = { ...config.tools?.web?.search, provider, enabled: true };
if (providerEntry) {
providerEntry.setCredentialValue(search as Record<string, unknown>, key);
}
const nextBase = {
...config,
tools: {
...config.tools,
web: { ...config.tools?.web, search },
web: {
...config.tools?.web,
search: { ...config.tools?.web?.search, provider, enabled: true },
},
},
};
if (providerEntry?.setConfiguredCredentialValue) {
providerEntry.setConfiguredCredentialValue(nextBase, key);
} else {
const search = nextBase.tools?.web?.search as Record<string, unknown> | undefined;
if (providerEntry && search) {
providerEntry.setCredentialValue(search, key);
}
}
return providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase;
}

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { validateConfigObject } from "./config.js";
import { validateConfigObjectWithPlugins } from "./config.js";
import { buildWebSearchProviderConfig } from "./test-helpers.js";
vi.mock("../runtime.js", () => ({
@@ -9,43 +9,55 @@ vi.mock("../runtime.js", () => ({
vi.mock("../plugins/web-search-providers.js", () => {
const getScoped = (key: string) => (search?: Record<string, unknown>) =>
(search?.[key] as { apiKey?: unknown } | undefined)?.apiKey;
const getConfigured = (pluginId: string) => (config?: Record<string, unknown>) =>
(
config?.plugins as
| { entries?: Record<string, { config?: { webSearch?: { apiKey?: unknown } } }> }
| undefined
)?.entries?.[pluginId]?.config?.webSearch?.apiKey;
return {
resolvePluginWebSearchProviders: () => [
{
id: "brave",
envVars: ["BRAVE_API_KEY"],
credentialPath: "tools.web.search.apiKey",
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
getCredentialValue: (search?: Record<string, unknown>) => search?.apiKey,
getConfiguredCredentialValue: getConfigured("brave"),
},
{
id: "firecrawl",
envVars: ["FIRECRAWL_API_KEY"],
credentialPath: "tools.web.search.firecrawl.apiKey",
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
getCredentialValue: getScoped("firecrawl"),
getConfiguredCredentialValue: getConfigured("firecrawl"),
},
{
id: "gemini",
envVars: ["GEMINI_API_KEY"],
credentialPath: "tools.web.search.gemini.apiKey",
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
getCredentialValue: getScoped("gemini"),
getConfiguredCredentialValue: getConfigured("google"),
},
{
id: "grok",
envVars: ["XAI_API_KEY"],
credentialPath: "tools.web.search.grok.apiKey",
credentialPath: "plugins.entries.xai.config.webSearch.apiKey",
getCredentialValue: getScoped("grok"),
getConfiguredCredentialValue: getConfigured("xai"),
},
{
id: "kimi",
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
credentialPath: "tools.web.search.kimi.apiKey",
credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey",
getCredentialValue: getScoped("kimi"),
getConfiguredCredentialValue: getConfigured("moonshot"),
},
{
id: "perplexity",
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
credentialPath: "tools.web.search.perplexity.apiKey",
credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey",
getCredentialValue: getScoped("perplexity"),
getConfiguredCredentialValue: getConfigured("perplexity"),
},
],
};
@@ -56,7 +68,7 @@ const { resolveSearchProvider } = __testing;
describe("web search provider config", () => {
it("accepts perplexity provider and config", () => {
const res = validateConfigObject(
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
enabled: true,
provider: "perplexity",
@@ -72,7 +84,7 @@ describe("web search provider config", () => {
});
it("accepts gemini provider and config", () => {
const res = validateConfigObject(
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
enabled: true,
provider: "gemini",
@@ -87,7 +99,7 @@ describe("web search provider config", () => {
});
it("accepts firecrawl provider and config", () => {
const res = validateConfigObject(
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
enabled: true,
provider: "firecrawl",
@@ -102,7 +114,7 @@ describe("web search provider config", () => {
});
it("accepts gemini provider with no extra config", () => {
const res = validateConfigObject(
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
provider: "gemini",
}),
@@ -112,7 +124,7 @@ describe("web search provider config", () => {
});
it("accepts brave llm-context mode config", () => {
const res = validateConfigObject(
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
provider: "brave",
providerConfig: {
@@ -125,7 +137,7 @@ describe("web search provider config", () => {
});
it("rejects invalid brave mode config values", () => {
const res = validateConfigObject(
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
provider: "brave",
providerConfig: {

View File

@@ -0,0 +1,146 @@
import type { OpenClawConfig } from "./config.js";
type JsonRecord = Record<string, unknown>;
const GENERIC_WEB_SEARCH_KEYS = new Set([
"enabled",
"provider",
"maxResults",
"timeoutSeconds",
"cacheTtlMinutes",
]);
const LEGACY_PROVIDER_MAP = {
brave: "brave",
firecrawl: "firecrawl",
gemini: "google",
grok: "xai",
kimi: "moonshot",
perplexity: "perplexity",
} as const;
type LegacyProviderId = keyof typeof LEGACY_PROVIDER_MAP;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cloneRecord<T extends JsonRecord>(value: T | undefined): T {
return { ...value } as T;
}
function ensureRecord(target: JsonRecord, key: string): JsonRecord {
const current = target[key];
if (isRecord(current)) {
return current;
}
const next: JsonRecord = {};
target[key] = next;
return next;
}
function resolveLegacySearchConfig(raw: unknown): JsonRecord | undefined {
if (!isRecord(raw)) {
return undefined;
}
const tools = isRecord(raw.tools) ? raw.tools : undefined;
const web = isRecord(tools?.web) ? tools.web : undefined;
return isRecord(web?.search) ? web.search : undefined;
}
function copyLegacyProviderConfig(
search: JsonRecord,
providerKey: LegacyProviderId,
): JsonRecord | undefined {
const current = search[providerKey];
return isRecord(current) ? cloneRecord(current) : undefined;
}
function setPluginWebSearchConfig(
target: JsonRecord,
pluginId: string,
webSearchConfig: JsonRecord,
): void {
const plugins = ensureRecord(target, "plugins");
const entries = ensureRecord(plugins, "entries");
const entry = ensureRecord(entries, pluginId);
if (entry.enabled === undefined) {
entry.enabled = true;
}
const config = ensureRecord(entry, "config");
config.webSearch = webSearchConfig;
}
export function listLegacyWebSearchConfigPaths(raw: unknown): string[] {
const search = resolveLegacySearchConfig(raw);
if (!search) {
return [];
}
const paths: string[] = [];
if ("apiKey" in search) {
paths.push("tools.web.search.apiKey");
}
for (const providerId of Object.keys(LEGACY_PROVIDER_MAP) as LegacyProviderId[]) {
const scoped = search[providerId];
if (isRecord(scoped)) {
for (const key of Object.keys(scoped)) {
paths.push(`tools.web.search.${providerId}.${key}`);
}
}
}
return paths;
}
export function normalizeLegacyWebSearchConfig<T>(raw: T): T {
if (!isRecord(raw)) {
return raw;
}
const search = resolveLegacySearchConfig(raw);
if (!search) {
return raw;
}
const nextRoot = cloneRecord(raw);
const tools = ensureRecord(nextRoot, "tools");
const web = ensureRecord(tools, "web");
const nextSearch: JsonRecord = {};
for (const [key, value] of Object.entries(search)) {
if (GENERIC_WEB_SEARCH_KEYS.has(key)) {
nextSearch[key] = value;
}
}
web.search = nextSearch;
const braveConfig = copyLegacyProviderConfig(search, "brave") ?? {};
if ("apiKey" in search) {
braveConfig.apiKey = search.apiKey;
}
if (Object.keys(braveConfig).length > 0) {
setPluginWebSearchConfig(nextRoot, LEGACY_PROVIDER_MAP.brave, braveConfig);
}
for (const providerId of ["firecrawl", "gemini", "grok", "kimi", "perplexity"] as const) {
const scoped = copyLegacyProviderConfig(search, providerId);
if (!scoped || Object.keys(scoped).length === 0) {
continue;
}
setPluginWebSearchConfig(nextRoot, LEGACY_PROVIDER_MAP[providerId], scoped);
}
return nextRoot as T;
}
export function resolvePluginWebSearchConfig(
config: OpenClawConfig | undefined,
pluginId: string,
): Record<string, unknown> | undefined {
const pluginConfig = config?.plugins?.entries?.[pluginId]?.config;
if (!isRecord(pluginConfig)) {
return undefined;
}
const webSearch = pluginConfig.webSearch;
return isRecord(webSearch) ? webSearch : undefined;
}

View File

@@ -667,33 +667,10 @@ export const FIELD_HELP: Record<string, string> = {
"tools.message.broadcast.enabled": "Enable broadcast action (default: true).",
"tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).",
"tools.web.search.provider":
'Search provider ("brave", "firecrawl", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.',
"tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
"Search provider id. Auto-detected from available API keys if omitted.",
"tools.web.search.maxResults": "Number of results to return (1-10).",
"tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.",
"tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.",
"tools.web.search.brave.mode":
'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).',
"tools.web.search.firecrawl.apiKey":
"Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).",
"tools.web.search.firecrawl.baseUrl":
'Firecrawl Search base URL override (default: "https://api.firecrawl.dev").',
"tools.web.search.gemini.apiKey":
"Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).",
"tools.web.search.gemini.model": 'Gemini model override (default: "gemini-2.5-flash").',
"tools.web.search.grok.apiKey": "Grok (xAI) API key (fallback: XAI_API_KEY env var).", // pragma: allowlist secret
"tools.web.search.grok.model": 'Grok model override (default: "grok-4-1-fast").',
"tools.web.search.kimi.apiKey":
"Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).",
"tools.web.search.kimi.baseUrl":
'Kimi base URL override (default: "https://api.moonshot.ai/v1").',
"tools.web.search.kimi.model": 'Kimi model override (default: "moonshot-v1-128k").',
"tools.web.search.perplexity.apiKey":
"Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). Direct Perplexity keys default to the Search API; OpenRouter keys use Sonar chat completions.",
"tools.web.search.perplexity.baseUrl":
"Optional Perplexity/OpenRouter chat-completions base URL override. Setting this opts Perplexity into the legacy Sonar/OpenRouter compatibility path.",
"tools.web.search.perplexity.model":
'Optional Sonar/OpenRouter model override (default: "perplexity/sonar-pro"). Setting this opts Perplexity into the legacy chat-completions compatibility path.',
"tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).",
"tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).",
"tools.web.fetch.maxCharsCap":

View File

@@ -216,23 +216,9 @@ export const FIELD_LABELS: Record<string, string> = {
"tools.message.broadcast.enabled": "Enable Message Broadcast",
"tools.web.search.enabled": "Enable Web Search Tool",
"tools.web.search.provider": "Web Search Provider",
"tools.web.search.apiKey": "Brave Search API Key",
"tools.web.search.maxResults": "Web Search Max Results",
"tools.web.search.timeoutSeconds": "Web Search Timeout (sec)",
"tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)",
"tools.web.search.brave.mode": "Brave Search Mode",
"tools.web.search.firecrawl.apiKey": "Firecrawl Search API Key", // pragma: allowlist secret
"tools.web.search.firecrawl.baseUrl": "Firecrawl Search Base URL",
"tools.web.search.gemini.apiKey": "Gemini Search API Key", // pragma: allowlist secret
"tools.web.search.gemini.model": "Gemini Search Model",
"tools.web.search.grok.apiKey": "Grok Search API Key", // pragma: allowlist secret
"tools.web.search.grok.model": "Grok Search Model",
"tools.web.search.kimi.apiKey": "Kimi Search API Key", // pragma: allowlist secret
"tools.web.search.kimi.baseUrl": "Kimi Search Base URL",
"tools.web.search.kimi.model": "Kimi Search Model",
"tools.web.search.perplexity.apiKey": "Perplexity API Key", // pragma: allowlist secret
"tools.web.search.perplexity.baseUrl": "Perplexity Base URL",
"tools.web.search.perplexity.model": "Perplexity Model",
"tools.web.fetch.enabled": "Enable Web Fetch Tool",
"tools.web.fetch.maxChars": "Web Fetch Max Chars",
"tools.web.fetch.maxCharsCap": "Web Fetch Hard Max Chars",

View File

@@ -64,14 +64,32 @@ export function buildWebSearchProviderConfig(params: {
if (params.enabled !== undefined) {
search.enabled = params.enabled;
}
if (params.providerConfig) {
search[params.provider] = params.providerConfig;
}
const pluginId =
params.provider === "gemini"
? "google"
: params.provider === "grok"
? "xai"
: params.provider === "kimi"
? "moonshot"
: params.provider;
return {
tools: {
web: {
search,
},
},
...(params.providerConfig
? {
plugins: {
entries: {
[pluginId]: {
config: {
webSearch: params.providerConfig,
},
},
},
},
}
: {}),
};
}

View File

@@ -457,62 +457,14 @@ export type ToolsConfig = {
search?: {
/** Enable web search tool (default: true when API key is present). */
enabled?: boolean;
/** Search provider ("brave", "firecrawl", "gemini", "grok", "kimi", or "perplexity"). */
provider?: "brave" | "firecrawl" | "gemini" | "grok" | "kimi" | "perplexity";
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
apiKey?: SecretInput;
/** Search provider id. */
provider?: string;
/** Default search results count (1-10). */
maxResults?: number;
/** Timeout in seconds for search requests. */
timeoutSeconds?: number;
/** Cache TTL in minutes for search results. */
cacheTtlMinutes?: number;
/** Brave-specific configuration (used when provider="brave"). */
brave?: {
/** Brave Search mode: "web" (standard results) or "llm-context" (pre-extracted page content). Default: "web". */
mode?: "web" | "llm-context";
};
/** Gemini-specific configuration (used when provider="gemini"). */
gemini?: {
/** Gemini API key (defaults to GEMINI_API_KEY env var). */
apiKey?: SecretInput;
/** Model to use for grounded search (defaults to "gemini-2.5-flash"). */
model?: string;
};
/** Firecrawl-specific configuration (used when provider="firecrawl"). */
firecrawl?: {
/** Firecrawl API key (defaults to FIRECRAWL_API_KEY env var). */
apiKey?: SecretInput;
/** Base URL for API requests (defaults to "https://api.firecrawl.dev"). */
baseUrl?: string;
};
/** Grok-specific configuration (used when provider="grok"). */
grok?: {
/** API key for xAI (defaults to XAI_API_KEY env var). */
apiKey?: SecretInput;
/** Model to use (defaults to "grok-4-1-fast"). */
model?: string;
/** Include inline citations in response text as markdown links (default: false). */
inlineCitations?: boolean;
};
/** Kimi-specific configuration (used when provider="kimi"). */
kimi?: {
/** Moonshot/Kimi API key (defaults to KIMI_API_KEY or MOONSHOT_API_KEY env var). */
apiKey?: SecretInput;
/** Base URL for API requests (defaults to "https://api.moonshot.ai/v1"). */
baseUrl?: string;
/** Model to use (defaults to "moonshot-v1-128k"). */
model?: string;
};
/** Perplexity-specific configuration (used when provider="perplexity"). */
perplexity?: {
/** API key for Perplexity (defaults to PERPLEXITY_API_KEY env var). */
apiKey?: SecretInput;
/** @deprecated Legacy Sonar/OpenRouter field. Ignored by Search API. */
baseUrl?: string;
/** @deprecated Legacy Sonar/OpenRouter field. Ignored by Search API. */
model?: string;
};
};
fetch?: {
/** Enable web fetch tool (default: true). */

View File

@@ -20,6 +20,10 @@ import { isRecord } from "../utils.js";
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js";
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
import {
listLegacyWebSearchConfigPaths,
normalizeLegacyWebSearchConfig,
} from "./legacy-web-search.js";
import { findLegacyConfigIssues } from "./legacy.js";
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
import { OpenClawSchema } from "./zod-schema.js";
@@ -229,7 +233,8 @@ function validateGatewayTailscaleBind(config: OpenClawConfig): ConfigValidationI
export function validateConfigObjectRaw(
raw: unknown,
): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } {
const legacyIssues = findLegacyConfigIssues(raw);
const normalizedRaw = normalizeLegacyWebSearchConfig(raw);
const legacyIssues = findLegacyConfigIssues(normalizedRaw);
if (legacyIssues.length > 0) {
return {
ok: false,
@@ -239,7 +244,7 @@ export function validateConfigObjectRaw(
})),
};
}
const validated = OpenClawSchema.safeParse(raw);
const validated = OpenClawSchema.safeParse(normalizedRaw);
if (!validated.success) {
return {
ok: false,
@@ -322,7 +327,12 @@ function validateConfigObjectWithPluginsBase(
const config = base.config;
const issues: ConfigValidationIssue[] = [];
const warnings: ConfigValidationIssue[] = [];
const warnings: ConfigValidationIssue[] = listLegacyWebSearchConfigPaths(raw).map((path) => ({
path,
message:
`${path} is deprecated for web search provider config. ` +
"Move it under plugins.entries.<plugin>.config.webSearch.*; OpenClaw mapped it automatically for compatibility.",
}));
const hasExplicitPluginsConfig =
isRecord(raw) && Object.prototype.hasOwnProperty.call(raw, "plugins");

View File

@@ -263,66 +263,10 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) =>
export const ToolsWebSearchSchema = z
.object({
enabled: z.boolean().optional(),
provider: z
.union([
z.literal("brave"),
z.literal("firecrawl"),
z.literal("perplexity"),
z.literal("grok"),
z.literal("gemini"),
z.literal("kimi"),
])
.optional(),
apiKey: SecretInputSchema.optional().register(sensitive),
provider: z.string().optional(),
maxResults: z.number().int().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(),
cacheTtlMinutes: z.number().nonnegative().optional(),
perplexity: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
// Legacy Sonar/OpenRouter compatibility fields.
// Setting either opts Perplexity back into the chat-completions path.
baseUrl: z.string().optional(),
model: z.string().optional(),
})
.strict()
.optional(),
grok: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
model: z.string().optional(),
inlineCitations: z.boolean().optional(),
})
.strict()
.optional(),
gemini: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
model: z.string().optional(),
})
.strict()
.optional(),
firecrawl: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
baseUrl: z.string().optional(),
})
.strict()
.optional(),
kimi: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
baseUrl: z.string().optional(),
model: z.string().optional(),
})
.strict()
.optional(),
brave: z
.object({
mode: z.union([z.literal("web"), z.literal("llm-context")]).optional(),
})
.strict()
.optional(),
})
.strict()
.optional();

View File

@@ -892,6 +892,8 @@ export type WebSearchProviderPlugin = {
inactiveSecretPaths?: string[];
getCredentialValue: (searchConfig?: Record<string, unknown>) => unknown;
setCredentialValue: (searchConfigTarget: Record<string, unknown>, value: unknown) => void;
getConfiguredCredentialValue?: (config?: OpenClawConfig) => unknown;
setConfiguredCredentialValue?: (configTarget: OpenClawConfig, value: unknown) => void;
applySelectionConfig?: (config: OpenClawConfig) => OpenClawConfig;
resolveRuntimeMetadata?: (
ctx: WebSearchRuntimeMetadataContext,

View File

@@ -23,12 +23,12 @@ describe("resolvePluginWebSearchProviders", () => {
"firecrawl:firecrawl",
]);
expect(providers.map((provider) => provider.credentialPath)).toEqual([
"tools.web.search.apiKey",
"tools.web.search.gemini.apiKey",
"tools.web.search.grok.apiKey",
"tools.web.search.kimi.apiKey",
"tools.web.search.perplexity.apiKey",
"tools.web.search.firecrawl.apiKey",
"plugins.entries.brave.config.webSearch.apiKey",
"plugins.entries.google.config.webSearch.apiKey",
"plugins.entries.xai.config.webSearch.apiKey",
"plugins.entries.moonshot.config.webSearch.apiKey",
"plugins.entries.perplexity.config.webSearch.apiKey",
"plugins.entries.firecrawl.config.webSearch.apiKey",
]);
expect(providers.find((provider) => provider.id === "firecrawl")?.applySelectionConfig).toEqual(
expect.any(Function),

View File

@@ -11,6 +11,19 @@ function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
function providerPluginId(provider: ProviderUnderTest): string {
switch (provider) {
case "gemini":
return "google";
case "grok":
return "xai";
case "kimi":
return "moonshot";
default:
return provider;
}
}
async function runRuntimeWebTools(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) {
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(params.config);
@@ -30,40 +43,32 @@ function createProviderSecretRefConfig(
provider: ProviderUnderTest,
envRefId: string,
): OpenClawConfig {
const search: Record<string, unknown> = {
enabled: true,
provider,
};
if (provider === "brave") {
search.apiKey = { source: "env", provider: "default", id: envRefId };
} else {
search[provider] = {
apiKey: { source: "env", provider: "default", id: envRefId },
};
}
return asConfig({
tools: {
web: {
search,
search: {
enabled: true,
provider,
},
},
},
plugins: {
entries: {
[providerPluginId(provider)]: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: envRefId },
},
},
},
},
},
});
}
function readProviderKey(config: OpenClawConfig, provider: ProviderUnderTest): unknown {
if (provider === "brave") {
return config.tools?.web?.search?.apiKey;
}
if (provider === "gemini") {
return config.tools?.web?.search?.gemini?.apiKey;
}
if (provider === "grok") {
return config.tools?.web?.search?.grok?.apiKey;
}
if (provider === "kimi") {
return config.tools?.web?.search?.kimi?.apiKey;
}
return config.tools?.web?.search?.perplexity?.apiKey;
return config.plugins?.entries?.[providerPluginId(provider)]?.config?.webSearch?.apiKey;
}
function expectInactiveFirecrawlSecretRef(params: {
@@ -171,18 +176,40 @@ describe("runtime web tools resolution", () => {
tools: {
web: {
search: {
apiKey: { source: "env", provider: "default", id: "BRAVE_REF" },
gemini: {
apiKey: { source: "env", provider: "default", id: "GEMINI_REF" },
enabled: true,
},
},
},
plugins: {
entries: {
brave: {
enabled: true,
config: {
webSearch: { apiKey: { source: "env", provider: "default", id: "BRAVE_REF" } },
},
grok: {
apiKey: { source: "env", provider: "default", id: "GROK_REF" },
},
google: {
enabled: true,
config: {
webSearch: { apiKey: { source: "env", provider: "default", id: "GEMINI_REF" } },
},
kimi: {
apiKey: { source: "env", provider: "default", id: "KIMI_REF" },
},
xai: {
enabled: true,
config: {
webSearch: { apiKey: { source: "env", provider: "default", id: "GROK_REF" } },
},
perplexity: {
apiKey: { source: "env", provider: "default", id: "PERPLEXITY_REF" },
},
moonshot: {
enabled: true,
config: {
webSearch: { apiKey: { source: "env", provider: "default", id: "KIMI_REF" } },
},
},
perplexity: {
enabled: true,
config: {
webSearch: { apiKey: { source: "env", provider: "default", id: "PERPLEXITY_REF" } },
},
},
},
@@ -199,13 +226,13 @@ describe("runtime web tools resolution", () => {
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("brave");
expect(resolvedConfig.tools?.web?.search?.apiKey).toBe("brave-precedence-key");
expect(readProviderKey(resolvedConfig, "brave")).toBe("brave-precedence-key");
expect(context.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "tools.web.search.gemini.apiKey" }),
expect.objectContaining({ path: "tools.web.search.grok.apiKey" }),
expect.objectContaining({ path: "tools.web.search.kimi.apiKey" }),
expect.objectContaining({ path: "tools.web.search.perplexity.apiKey" }),
expect.objectContaining({ path: "plugins.entries.google.config.webSearch.apiKey" }),
expect.objectContaining({ path: "plugins.entries.xai.config.webSearch.apiKey" }),
expect.objectContaining({ path: "plugins.entries.moonshot.config.webSearch.apiKey" }),
expect.objectContaining({ path: "plugins.entries.perplexity.config.webSearch.apiKey" }),
]),
);
});
@@ -216,12 +243,25 @@ describe("runtime web tools resolution", () => {
tools: {
web: {
search: {
apiKey: { source: "env", provider: "default", id: "BRAVE_API_KEY_REF" },
gemini: {
apiKey: {
source: "env",
provider: "default",
id: "MISSING_GEMINI_API_KEY_REF",
enabled: true,
},
},
},
plugins: {
entries: {
brave: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "BRAVE_API_KEY_REF" },
},
},
},
google: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "MISSING_GEMINI_API_KEY_REF" },
},
},
},
@@ -236,8 +276,8 @@ describe("runtime web tools resolution", () => {
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("brave");
expect(metadata.search.selectedProviderKeySource).toBe("secretRef");
expect(resolvedConfig.tools?.web?.search?.apiKey).toBe("brave-runtime-key");
expect(resolvedConfig.tools?.web?.search?.gemini?.apiKey).toEqual({
expect(readProviderKey(resolvedConfig, "brave")).toBe("brave-runtime-key");
expect(readProviderKey(resolvedConfig, "gemini")).toEqual({
source: "env",
provider: "default",
id: "MISSING_GEMINI_API_KEY_REF",
@@ -246,7 +286,7 @@ describe("runtime web tools resolution", () => {
expect.arrayContaining([
expect.objectContaining({
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "tools.web.search.gemini.apiKey",
path: "plugins.entries.google.config.webSearch.apiKey",
}),
]),
);
@@ -261,9 +301,26 @@ describe("runtime web tools resolution", () => {
tools: {
web: {
search: {
apiKey: { source: "env", provider: "default", id: "MISSING_BRAVE_API_KEY_REF" },
gemini: {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY_REF" },
enabled: true,
},
},
},
plugins: {
entries: {
brave: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "MISSING_BRAVE_API_KEY_REF" },
},
},
},
google: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY_REF" },
},
},
},
},
@@ -276,12 +333,12 @@ describe("runtime web tools resolution", () => {
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("gemini");
expect(resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe("gemini-runtime-key");
expect(readProviderKey(resolvedConfig, "gemini")).toBe("gemini-runtime-key");
expect(context.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "tools.web.search.apiKey",
path: "plugins.entries.brave.config.webSearch.apiKey",
}),
]),
);
@@ -297,8 +354,17 @@ describe("runtime web tools resolution", () => {
web: {
search: {
provider: "invalid-provider",
gemini: {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY_REF" },
},
},
},
plugins: {
entries: {
google: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY_REF" },
},
},
},
},
@@ -312,7 +378,7 @@ describe("runtime web tools resolution", () => {
expect(metadata.search.providerConfigured).toBeUndefined();
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("gemini");
expect(resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe("gemini-runtime-key");
expect(readProviderKey(resolvedConfig, "gemini")).toBe("gemini-runtime-key");
expect(metadata.search.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -337,8 +403,17 @@ describe("runtime web tools resolution", () => {
web: {
search: {
provider: "gemini",
gemini: {
apiKey: { source: "env", provider: "default", id: "MISSING_GEMINI_API_KEY_REF" },
},
},
},
plugins: {
entries: {
google: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "MISSING_GEMINI_API_KEY_REF" },
},
},
},
},
@@ -361,7 +436,7 @@ describe("runtime web tools resolution", () => {
expect.arrayContaining([
expect.objectContaining({
code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
path: "tools.web.search.gemini.apiKey",
path: "plugins.entries.google.config.webSearch.apiKey",
}),
]),
);

View File

@@ -221,6 +221,9 @@ function setResolvedWebSearchApiKey(params: {
env: params.env,
bundledAllowlistCompat: true,
}).find((entry) => entry.id === params.provider);
if (provider?.setConfiguredCredentialValue) {
provider.setConfiguredCredentialValue(params.resolvedConfig, params.value);
}
provider?.setCredentialValue(search, params.value);
}
@@ -318,7 +321,9 @@ export async function resolveRuntimeWebTools(params: {
for (const provider of candidates) {
const path = keyPathForProvider(provider);
const value = provider.getCredentialValue(search);
const value =
provider.getConfiguredCredentialValue?.(params.sourceConfig) ??
provider.getCredentialValue(search);
const resolution = await resolveSecretInputWithEnvFallback({
sourceConfig: params.sourceConfig,
context: params.context,
@@ -451,7 +456,9 @@ export async function resolveRuntimeWebTools(params: {
if (provider.id === searchMetadata.selectedProvider) {
continue;
}
const value = provider.getCredentialValue(search);
const value =
provider.getConfiguredCredentialValue?.(params.sourceConfig) ??
provider.getCredentialValue(search);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
@@ -465,7 +472,9 @@ export async function resolveRuntimeWebTools(params: {
}
} else if (search && !searchEnabled) {
for (const provider of providers) {
const value = provider.getCredentialValue(search);
const value =
provider.getConfiguredCredentialValue?.(params.sourceConfig) ??
provider.getCredentialValue(search);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
@@ -484,7 +493,9 @@ export async function resolveRuntimeWebTools(params: {
if (provider.id === configuredProvider) {
continue;
}
const value = provider.getCredentialValue(search);
const value =
provider.getConfiguredCredentialValue?.(params.sourceConfig) ??
provider.getCredentialValue(search);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}

View File

@@ -733,6 +733,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
includeInConfigure: true,
includeInAudit: true,
},
{
id: "plugins.entries.brave.config.webSearch.apiKey",
targetType: "plugins.entries.brave.config.webSearch.apiKey",
configFile: "openclaw.json",
pathPattern: "plugins.entries.brave.config.webSearch.apiKey",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.search.gemini.apiKey",
targetType: "tools.web.search.gemini.apiKey",
@@ -744,6 +755,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
includeInConfigure: true,
includeInAudit: true,
},
{
id: "plugins.entries.google.config.webSearch.apiKey",
targetType: "plugins.entries.google.config.webSearch.apiKey",
configFile: "openclaw.json",
pathPattern: "plugins.entries.google.config.webSearch.apiKey",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.search.grok.apiKey",
targetType: "tools.web.search.grok.apiKey",
@@ -755,6 +777,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
includeInConfigure: true,
includeInAudit: true,
},
{
id: "plugins.entries.xai.config.webSearch.apiKey",
targetType: "plugins.entries.xai.config.webSearch.apiKey",
configFile: "openclaw.json",
pathPattern: "plugins.entries.xai.config.webSearch.apiKey",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.search.kimi.apiKey",
targetType: "tools.web.search.kimi.apiKey",
@@ -766,6 +799,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
includeInConfigure: true,
includeInAudit: true,
},
{
id: "plugins.entries.moonshot.config.webSearch.apiKey",
targetType: "plugins.entries.moonshot.config.webSearch.apiKey",
configFile: "openclaw.json",
pathPattern: "plugins.entries.moonshot.config.webSearch.apiKey",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.search.perplexity.apiKey",
targetType: "tools.web.search.perplexity.apiKey",
@@ -777,6 +821,28 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
includeInConfigure: true,
includeInAudit: true,
},
{
id: "plugins.entries.perplexity.config.webSearch.apiKey",
targetType: "plugins.entries.perplexity.config.webSearch.apiKey",
configFile: "openclaw.json",
pathPattern: "plugins.entries.perplexity.config.webSearch.apiKey",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "plugins.entries.firecrawl.config.webSearch.apiKey",
targetType: "plugins.entries.firecrawl.config.webSearch.apiKey",
configFile: "openclaw.json",
pathPattern: "plugins.entries.firecrawl.config.webSearch.apiKey",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
];
export { SECRET_TARGET_REGISTRY };

View File

@@ -61,22 +61,26 @@ function readProviderEnvValue(envVars: string[]): string | undefined {
return undefined;
}
function hasProviderCredential(providerId: string, search: WebSearchConfig | undefined): boolean {
function hasProviderCredential(
providerId: string,
config: OpenClawConfig | undefined,
search: WebSearchConfig | undefined,
): boolean {
const providers = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
});
const provider = providers.find((entry) => entry.id === providerId);
if (!provider) {
return false;
}
const rawValue = provider.getCredentialValue(search as Record<string, unknown> | undefined);
const rawValue =
provider.getConfiguredCredentialValue?.(config) ??
provider.getCredentialValue(search as Record<string, unknown> | undefined);
const fromConfig = normalizeSecretInput(
normalizeResolvedSecretInputString({
value: rawValue,
path:
providerId === "brave"
? "tools.web.search.apiKey"
: `tools.web.search.${providerId}.apiKey`,
path: provider.credentialPath,
}),
);
return Boolean(fromConfig || readProviderEnvValue(provider.envVars));
@@ -93,11 +97,13 @@ export function listWebSearchProviders(params?: {
export function resolveWebSearchProviderId(params: {
search?: WebSearchConfig;
config?: OpenClawConfig;
providers?: PluginWebSearchProviderEntry[];
}): string {
const providers =
params.providers ??
resolvePluginWebSearchProviders({
config: params.config,
bundledAllowlistCompat: true,
});
const raw =
@@ -114,7 +120,7 @@ export function resolveWebSearchProviderId(params: {
if (!raw) {
for (const provider of providers) {
if (!hasProviderCredential(provider.id, params.search)) {
if (!hasProviderCredential(provider.id, params.config, params.search)) {
continue;
}
logVerbose(
@@ -124,7 +130,7 @@ export function resolveWebSearchProviderId(params: {
}
}
return providers[0]?.id ?? "brave";
return providers[0]?.id ?? "";
}
export function resolveWebSearchDefinition(
@@ -154,10 +160,13 @@ export function resolveWebSearchDefinition(
options?.providerId ??
options?.runtimeWebSearch?.selectedProvider ??
options?.runtimeWebSearch?.providerConfigured ??
resolveWebSearchProviderId({ search, providers });
resolveWebSearchProviderId({ config: options?.config, search, providers });
const provider =
providers.find((entry) => entry.id === providerId) ??
providers.find((entry) => entry.id === resolveWebSearchProviderId({ search, providers })) ??
providers.find(
(entry) =>
entry.id === resolveWebSearchProviderId({ config: options?.config, search, providers }),
) ??
providers[0];
if (!provider) {
return null;