refactor: unify search provider onboarding metadata

This commit is contained in:
Tak Hoffman 2026-03-15 13:42:07 -05:00
parent 98c5c04608
commit 74b5c2e875
16 changed files with 397 additions and 210 deletions

View File

@ -1,6 +1,6 @@
import {
CacheEntry,
createLegacySearchProviderMetadata,
createSearchProviderSetupMetadata,
createMissingSearchKeyPayload,
formatCliCommand,
normalizeCacheKey,
@ -15,7 +15,7 @@ import {
type SearchProviderContext,
type SearchProviderErrorResult,
type SearchProviderExecutionResult,
type SearchProviderLegacyUiMetadata,
type SearchProviderSetupUiMetadata,
type SearchProviderPlugin,
type SearchProviderRequest,
withTrustedWebToolsEndpoint,
@ -395,8 +395,8 @@ async function runBraveWebSearch(params: {
);
}
export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
createLegacySearchProviderMetadata({
export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
createSearchProviderSetupMetadata({
provider: "brave",
label: "Brave Search",
hint: "Structured results · country/language/time filters",
@ -414,7 +414,10 @@ export function createBundledBraveSearchProvider(): SearchProviderPlugin {
"Search the web using Brave Search. Supports web and llm-context modes, region-specific search, and localized search parameters.",
pluginOwnedExecution: true,
docsUrl: BRAVE_SEARCH_PROVIDER_METADATA.signupUrl,
legacyConfig: BRAVE_SEARCH_PROVIDER_METADATA,
setup: {
hint: BRAVE_SEARCH_PROVIDER_METADATA.hint,
credentials: BRAVE_SEARCH_PROVIDER_METADATA,
},
isAvailable: (config) => {
const search = config?.tools?.web?.search;
return Boolean(

View File

@ -1,6 +1,6 @@
import {
buildSearchRequestCacheIdentity,
createLegacySearchProviderMetadata,
createSearchProviderSetupMetadata,
createMissingSearchKeyPayload,
normalizeCacheKey,
normalizeSecretInput,
@ -12,7 +12,7 @@ import {
resolveSearchProviderSectionConfig,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderLegacyUiMetadata,
type SearchProviderSetupUiMetadata,
type SearchProviderPlugin,
withTrustedWebToolsEndpoint,
wrapWebContent,
@ -156,8 +156,8 @@ async function runGeminiSearch(params: {
);
}
export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
createLegacySearchProviderMetadata({
export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
createSearchProviderSetupMetadata({
provider: "gemini",
label: "Gemini (Google Search)",
hint: "Google Search grounding · AI-synthesized",
@ -174,7 +174,10 @@ export function createBundledGeminiSearchProvider(): SearchProviderPlugin {
description:
"Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.",
pluginOwnedExecution: true,
legacyConfig: GEMINI_SEARCH_PROVIDER_METADATA,
setup: {
hint: GEMINI_SEARCH_PROVIDER_METADATA.hint,
credentials: GEMINI_SEARCH_PROVIDER_METADATA,
},
isAvailable: (config) =>
Boolean(
resolveGeminiApiKey(

View File

@ -1,6 +1,6 @@
import {
buildSearchRequestCacheIdentity,
createLegacySearchProviderMetadata,
createSearchProviderSetupMetadata,
createMissingSearchKeyPayload,
normalizeCacheKey,
normalizeSecretInput,
@ -11,7 +11,7 @@ import {
throwWebSearchApiError,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderLegacyUiMetadata,
type SearchProviderSetupUiMetadata,
type SearchProviderPlugin,
withTrustedWebToolsEndpoint,
wrapWebContent,
@ -164,8 +164,8 @@ async function runGrokSearch(params: {
);
}
export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
createLegacySearchProviderMetadata({
export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
createSearchProviderSetupMetadata({
provider: "grok",
label: "Grok (xAI)",
hint: "xAI web-grounded responses",
@ -182,7 +182,10 @@ export function createBundledGrokSearchProvider(): SearchProviderPlugin {
description:
"Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.",
pluginOwnedExecution: true,
legacyConfig: GROK_SEARCH_PROVIDER_METADATA,
setup: {
hint: GROK_SEARCH_PROVIDER_METADATA.hint,
credentials: GROK_SEARCH_PROVIDER_METADATA,
},
isAvailable: (config) =>
Boolean(
resolveGrokApiKey(

View File

@ -1,6 +1,6 @@
import {
buildSearchRequestCacheIdentity,
createLegacySearchProviderMetadata,
createSearchProviderSetupMetadata,
createMissingSearchKeyPayload,
normalizeCacheKey,
normalizeSecretInput,
@ -10,7 +10,7 @@ import {
resolveSearchProviderSectionConfig,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderLegacyUiMetadata,
type SearchProviderSetupUiMetadata,
type SearchProviderPlugin,
withTrustedWebToolsEndpoint,
wrapWebContent,
@ -216,8 +216,8 @@ async function runKimiSearch(params: {
};
}
export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
createLegacySearchProviderMetadata({
export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
createSearchProviderSetupMetadata({
provider: "kimi",
label: "Kimi (Moonshot)",
hint: "Moonshot web search",
@ -234,7 +234,10 @@ export function createBundledKimiSearchProvider(): SearchProviderPlugin {
description:
"Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.",
pluginOwnedExecution: true,
legacyConfig: KIMI_SEARCH_PROVIDER_METADATA,
setup: {
hint: KIMI_SEARCH_PROVIDER_METADATA.hint,
credentials: KIMI_SEARCH_PROVIDER_METADATA,
},
isAvailable: (config) =>
Boolean(
resolveKimiApiKey(

View File

@ -1,6 +1,6 @@
import {
buildSearchRequestCacheIdentity,
createLegacySearchProviderMetadata,
createSearchProviderSetupMetadata,
createMissingSearchKeyPayload,
createSearchProviderErrorResult,
normalizeCacheKey,
@ -14,7 +14,7 @@ import {
throwWebSearchApiError,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderLegacyUiMetadata,
type SearchProviderSetupUiMetadata,
type SearchProviderPlugin,
withTrustedWebToolsEndpoint,
wrapWebContent,
@ -376,8 +376,8 @@ function createPerplexityPayload(params: {
return payload;
}
export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
createLegacySearchProviderMetadata({
export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
createSearchProviderSetupMetadata({
provider: "perplexity",
label: "Perplexity Search",
hint: "Structured results · domain/country/language/time filters",
@ -399,7 +399,10 @@ export function createBundledPerplexitySearchProvider(): SearchProviderPlugin {
description:
"Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path.",
pluginOwnedExecution: true,
legacyConfig: PERPLEXITY_SEARCH_PROVIDER_METADATA,
setup: {
hint: PERPLEXITY_SEARCH_PROVIDER_METADATA.hint,
credentials: PERPLEXITY_SEARCH_PROVIDER_METADATA,
},
resolveRuntimeMetadata: PERPLEXITY_SEARCH_PROVIDER_METADATA.resolveRuntimeMetadata,
isAvailable: (config) =>
Boolean(

View File

@ -787,11 +787,11 @@ describe("runConfigureWizard", () => {
if (params.message === "Choose active web search provider") {
expect(params.options?.[0]).toMatchObject({
value: "tavily",
hint: "Plugin search · External plugin",
hint: "Plugin search",
});
expect(params.options?.[1]).toMatchObject({
value: "__install_plugin__",
hint: "Add an external web search plugin",
hint: "Install a web search plugin from npm or a local path",
});
return "tavily";
}

View File

@ -5,7 +5,6 @@ import {
listTelegramAccountIds,
resolveTelegramAccount,
} from "../../extensions/telegram/src/accounts.js";
import { isBuiltinWebSearchProviderId } from "../agents/tools/web-search-provider-catalog.js";
import {
isNumericTelegramUserId,
normalizeTelegramAllowFromEntry,
@ -240,7 +239,7 @@ function maybeRepairInvalidPluginConfig(candidate: OpenClawConfig): {
issue.path === "tools.web.search.provider" &&
issue.message.startsWith("unknown web search provider:"),
);
if (hasProviderIssue && activeProvider && !isBuiltinWebSearchProviderId(activeProvider)) {
if (hasProviderIssue && activeProvider) {
if (next.tools?.web?.search) {
delete next.tools.web.search.provider;
changes.push(

View File

@ -31,7 +31,7 @@ vi.mock("./onboarding/plugin-install.js", () => ({
reloadOnboardingPluginRegistry,
}));
import { SEARCH_PROVIDER_OPTIONS, setupSearch } from "./onboard-search.js";
import { setupSearch } from "./onboard-search.js";
const runtime: RuntimeEnv = {
log: vi.fn(),
@ -174,7 +174,7 @@ describe("setupSearch", () => {
expect.objectContaining({
value: "tavily",
label: "Tavily Search",
hint: expect.stringContaining("Plugin search · External plugin"),
hint: expect.stringContaining("Plugin search"),
}),
]),
}),
@ -233,7 +233,7 @@ describe("setupSearch", () => {
expect.objectContaining({
value: "tavily",
label: "Tavily Search",
hint: expect.stringContaining("Bundled plugin"),
hint: expect.stringContaining("Search the web using Tavily."),
}),
]),
}),
@ -242,7 +242,7 @@ describe("setupSearch", () => {
expect.arrayContaining([
expect.objectContaining({
value: "__install_plugin__",
label: "Install external provider plugin",
label: "Install provider plugin",
}),
]),
);
@ -301,7 +301,7 @@ describe("setupSearch", () => {
(option: { value?: string }) => option.value === providerId,
) ?? [];
expect(matchingOptions).toHaveLength(1);
expect(matchingOptions[0]?.hint).toContain("Bundled plugin");
expect(matchingOptions[0]?.hint).toContain(`Bundled ${providerLabel} provider`);
},
);
@ -363,7 +363,7 @@ describe("setupSearch", () => {
options: expect.arrayContaining([
expect.objectContaining({
value: "__install_plugin__",
label: "Install external provider plugin",
label: "Install provider plugin",
}),
]),
}),
@ -466,11 +466,11 @@ describe("setupSearch", () => {
)?.[0]?.options;
expect(options[0]).toMatchObject({
value: "tavily",
hint: "Plugin search · External plugin · Active now",
hint: "Plugin search",
});
expect(options[1]).toMatchObject({
value: "brave",
hint: "Structured results · country/language/time filters · Built-in · Configured",
value: "__install_plugin__",
hint: "Install a web search plugin from npm or a local path",
});
});
@ -539,8 +539,8 @@ describe("setupSearch", () => {
}),
expect.objectContaining({
value: "__install_plugin__",
label: "Install external provider plugin",
hint: "Add an external web search plugin",
label: "Install provider plugin",
hint: "Add a web search plugin",
}),
]),
}),
@ -1541,9 +1541,28 @@ describe("setupSearch", () => {
expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain");
});
it("exports all 5 providers in SEARCH_PROVIDER_OPTIONS", () => {
expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(5);
const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value);
expect(values).toEqual(["brave", "gemini", "grok", "kimi", "perplexity"]);
it("keeps the install option generic", async () => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [],
plugins: [],
typedHooks: [],
});
loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] });
const { prompter } = createPrompter({ selectValue: "__skip__" });
await setupSearch({}, runtime, prompter);
const providerSelectCall = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0]?.message === "Choose active web search provider",
);
expect(providerSelectCall?.[0]?.options).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: "__install_plugin__",
label: "Install provider plugin",
hint: "Add a web search plugin",
}),
]),
);
});
});

View File

@ -1,4 +1,3 @@
import { isBuiltinWebSearchProviderId } from "../agents/tools/web-search-provider-catalog.js";
import type { OpenClawConfig } from "../config/config.js";
import {
DEFAULT_SECRET_PROVIDER_ALIAS,
@ -18,8 +17,9 @@ import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import type {
PluginConfigUiHint,
PluginOrigin,
SearchProviderCredentialMetadata,
SearchProviderLegacyConfigMetadata,
SearchProviderSetupMetadata,
} from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
@ -53,13 +53,12 @@ type PluginSearchProviderEntry = {
hint: string;
configured: boolean;
pluginId: string;
origin: PluginOrigin;
description: string | undefined;
docsUrl: string | undefined;
configFieldOrder?: string[];
configJsonSchema?: Record<string, unknown>;
configUiHints?: Record<string, PluginConfigUiHint>;
legacyConfig?: SearchProviderLegacyConfigMetadata;
setup?: SearchProviderSetupMetadata;
};
export type SearchProviderPickerEntry = PluginSearchProviderEntry;
@ -96,7 +95,7 @@ type PluginPromptableField =
type SearchProviderHookDetails = {
providerId: string;
providerLabel: string;
providerSource: "builtin" | "plugin";
providerSource: "plugin";
pluginId?: string;
configured: boolean;
};
@ -119,8 +118,26 @@ function humanizeConfigKey(value: string): string {
.replace(/^\w/, (char) => char.toUpperCase());
}
function formatPluginSourceHint(origin: PluginOrigin): string {
return origin === "bundled" ? "Bundled plugin" : "External plugin";
function resolveProviderSetupMetadata(
setup?: SearchProviderSetupMetadata,
legacyConfig?: SearchProviderLegacyConfigMetadata,
): SearchProviderSetupMetadata | undefined {
if (setup) {
return setup;
}
if (!legacyConfig) {
return undefined;
}
return {
hint: legacyConfig.hint,
credentials: legacyConfig,
};
}
function resolveProviderCredentialMetadata(
setup?: SearchProviderSetupMetadata,
): SearchProviderCredentialMetadata | undefined {
return setup?.credentials;
}
export function resolveInstallableSearchProviderPlugins(
@ -129,17 +146,11 @@ export function resolveInstallableSearchProviderPlugins(
const loadedPluginProviderIds = new Set(
providerEntries.filter((entry) => entry.kind === "plugin").map((entry) => entry.value),
);
return SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.filter((entry) => {
const providerEntry = providerEntries.find(
(providerEntry) =>
providerEntry.kind === "plugin" && providerEntry.value === entry.providerId,
);
return providerEntry?.kind !== "plugin" || providerEntry.origin !== "bundled";
}).map((entry) => ({
return SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.filter(
(entry) => !loadedPluginProviderIds.has(entry.providerId),
).map((entry) => ({
...entry,
description: loadedPluginProviderIds.has(entry.providerId)
? `${entry.description} Already installed.`
: entry.description,
description: entry.description,
}));
}
@ -534,15 +545,16 @@ export async function resolveSearchProviderPickerEntries(
configured = false;
}
const sourceHint = formatPluginSourceHint(pluginRecord.origin);
const setup = resolveProviderSetupMetadata(
registration.provider.setup,
registration.provider.legacyConfig,
);
const baseHint =
registration.provider.legacyConfig?.hint?.trim() ||
setup?.hint?.trim() ||
registration.provider.description?.trim() ||
pluginRecord.description?.trim() ||
"Plugin-provided web search";
const hint = configured
? `${baseHint} · ${sourceHint} · configured`
: `${baseHint} · ${sourceHint}`;
const hint = configured ? `${baseHint} · configured` : baseHint;
return {
kind: "plugin" as const,
@ -551,13 +563,12 @@ export async function resolveSearchProviderPickerEntries(
hint,
configured,
pluginId: registration.pluginId,
origin: pluginRecord.origin,
description: registration.provider.description,
docsUrl: registration.provider.docsUrl,
configFieldOrder: registration.provider.configFieldOrder,
configJsonSchema: pluginRecord.configJsonSchema,
configUiHints: pluginRecord.configUiHints,
legacyConfig: registration.provider.legacyConfig,
setup,
};
})
.filter(Boolean)
@ -585,7 +596,7 @@ export async function resolveSearchProviderPickerEntries(
)
.filter(
(entry): entry is PluginSearchProviderEntry =>
Boolean(entry) && entry.origin === "bundled" && !loadedPluginProviderIds.has(entry.value),
Boolean(entry) && !loadedPluginProviderIds.has(entry.value),
)
.map((entry) => {
const pluginConfig = getPluginConfig(config, entry.pluginId);
@ -624,7 +635,6 @@ function buildPluginSearchProviderEntryFromManifestRecord(pluginRecord: {
id: string;
name?: string;
description?: string;
origin: PluginOrigin;
configSchema?: Record<string, unknown>;
configUiHints?: Record<string, PluginConfigUiHint>;
provides: string[];
@ -634,25 +644,19 @@ function buildPluginSearchProviderEntryFromManifestRecord(pluginRecord: {
return undefined;
}
const hintParts = [
pluginRecord.description || "Plugin-provided web search",
formatPluginSourceHint(pluginRecord.origin),
];
return {
kind: "plugin",
value: providerId,
label: pluginRecord.name || providerId,
hint: hintParts.join(" · "),
hint: pluginRecord.description || "Plugin-provided web search",
configured: false,
pluginId: pluginRecord.id,
origin: pluginRecord.origin,
description: pluginRecord.description,
docsUrl: undefined,
configFieldOrder: undefined,
configJsonSchema: pluginRecord.configSchema,
configUiHints: pluginRecord.configUiHints,
legacyConfig: undefined,
setup: undefined,
};
}
@ -851,13 +855,7 @@ function formatPickerEntryHint(params: {
configuredCount: number;
}): string {
const { entry, isActive, configuredCount } = params;
const baseParts =
entry.kind === "plugin"
? [
entry.description?.trim() || "Plugin-provided web search",
formatPluginSourceHint(entry.origin),
]
: [entry.hint, "Built-in"];
const baseParts = [entry.description?.trim() || entry.hint || "Plugin-provided web search"];
if (configuredCount > 1) {
if (entry.configured) {
@ -874,11 +872,7 @@ export function buildSearchProviderPickerModel(
const { config, providerEntries, includeSkipOption, skipHint } = params;
const existingProvider = resolveCapabilitySlotSelection(config, "providers.search");
const existingPluginProvider =
typeof existingProvider === "string" &&
existingProvider.trim() &&
!isBuiltinWebSearchProviderId(existingProvider)
? existingProvider
: undefined;
typeof existingProvider === "string" && existingProvider.trim() ? existingProvider : undefined;
const loadedExistingPluginProvider =
existingPluginProvider &&
providerEntries.some(
@ -921,7 +915,7 @@ export function buildSearchProviderPickerModel(
{
value: SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL as const,
label: `Keep current provider (${unloadedExistingPluginProvider})`,
hint: "Leave the current plugin-managed web_search provider unchanged",
hint: "Leave the current web search provider unchanged",
},
]
: []),
@ -936,11 +930,11 @@ export function buildSearchProviderPickerModel(
})),
{
value: SEARCH_PROVIDER_INSTALL_SENTINEL as const,
label: "Install external provider plugin",
label: "Install provider plugin",
hint:
installableEntries.length > 0
? "Add an external web search plugin"
: "Install an external web search plugin from npm or a local path",
? "Add a web search plugin"
: "Install a web search plugin from npm or a local path",
},
...(includeSkipOption
? [
@ -988,15 +982,16 @@ export async function configureSearchProviderSelection(
intent === "switch-active"
? setWebSearchProvider(enabled.config, selectedEntry.value)
: enabled.config;
const legacyConfig = selectedEntry.legacyConfig;
const existingKey = legacyConfig ? resolveExistingKey(config, legacyConfig) : undefined;
const keyConfigured = legacyConfig ? hasExistingKey(config, legacyConfig) : false;
const envAvailable =
legacyConfig?.envKeys?.some((key) => Boolean(process.env[key]?.trim())) ?? false;
const credentialMetadata = resolveProviderCredentialMetadata(selectedEntry.setup);
const existingKey = credentialMetadata
? resolveExistingKey(config, credentialMetadata)
: undefined;
const keyConfigured = credentialMetadata ? hasExistingKey(config, credentialMetadata) : false;
const envAvailable = credentialMetadata ? hasKeyInEnv(credentialMetadata) : false;
if (legacyConfig && intent === "switch-active" && (keyConfigured || envAvailable)) {
if (credentialMetadata && intent === "switch-active" && (keyConfigured || envAvailable)) {
const result = existingKey
? applySearchKey(config, selectedEntry.value, legacyConfig, existingKey)
? applySearchKey(config, selectedEntry.value, credentialMetadata, existingKey)
: applyProviderOnly(config, selectedEntry.value);
const nextConfig = preserveSearchProviderIntent(config, result, intent, selectedEntry.value);
await runAfterSearchProviderHooks({
@ -1041,7 +1036,7 @@ export async function configureSearchProviderSelection(
prompter,
workspaceDir: opts?.workspaceDir,
});
if (legacyConfig) {
if (credentialMetadata) {
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
if (useSecretRefMode) {
if (keyConfigured) {
@ -1052,7 +1047,7 @@ export async function configureSearchProviderSelection(
selectedEntry.value,
);
}
const ref = buildSearchEnvRef(legacyConfig);
const ref = buildSearchEnvRef(credentialMetadata);
await prompter.note(
[
"Secret references enabled — OpenClaw will store a reference instead of the API key.",
@ -1064,7 +1059,7 @@ export async function configureSearchProviderSelection(
);
const result = preserveSearchProviderIntent(
config,
applySearchKey(config, selectedEntry.value, legacyConfig, ref),
applySearchKey(config, selectedEntry.value, credentialMetadata, ref),
intent,
selectedEntry.value,
);
@ -1085,20 +1080,20 @@ export async function configureSearchProviderSelection(
: envAvailable
? `${selectedEntry.label} API key (leave blank to use env var)`
: `${selectedEntry.label} API key`,
placeholder: keyConfigured ? "Leave blank to keep current" : legacyConfig.placeholder,
placeholder: keyConfigured ? "Leave blank to keep current" : credentialMetadata.placeholder,
});
const key = keyInput?.trim() ?? "";
if (key) {
const secretInput = resolveSearchSecretInput(
selectedEntry.value,
legacyConfig,
credentialMetadata,
key,
opts?.secretInputMode,
);
const result = preserveSearchProviderIntent(
config,
applySearchKey(config, selectedEntry.value, legacyConfig, secretInput),
applySearchKey(config, selectedEntry.value, credentialMetadata, secretInput),
intent,
selectedEntry.value,
);
@ -1116,7 +1111,7 @@ export async function configureSearchProviderSelection(
if (existingKey) {
const result = preserveSearchProviderIntent(
config,
applySearchKey(config, selectedEntry.value, legacyConfig, existingKey),
applySearchKey(config, selectedEntry.value, credentialMetadata, existingKey),
intent,
selectedEntry.value,
);
@ -1151,9 +1146,9 @@ export async function configureSearchProviderSelection(
await prompter.note(
[
`Get your key at: ${legacyConfig.signupUrl}`,
`Get your key at: ${credentialMetadata.signupUrl}`,
envAvailable
? `OpenClaw can also use ${legacyConfig.envKeys?.find((k) => Boolean(process.env[k]?.trim()))}.`
? `OpenClaw can also use ${credentialMetadata.envKeys?.find((k) => Boolean(process.env[k]?.trim()))}.`
: undefined,
]
.filter(Boolean)
@ -1289,14 +1284,11 @@ export async function promptSearchProviderFlow(params: {
});
}
export function hasKeyInEnv(metadata: SearchProviderLegacyConfigMetadata): boolean {
return metadata.envKeys.some((key) => Boolean(process.env[key]?.trim()));
export function hasKeyInEnv(metadata: SearchProviderCredentialMetadata): boolean {
return metadata.envKeys?.some((key) => Boolean(process.env[key]?.trim())) ?? false;
}
function rawKeyValue(
config: OpenClawConfig,
metadata: SearchProviderLegacyConfigMetadata,
): unknown {
function rawKeyValue(config: OpenClawConfig, metadata: SearchProviderCredentialMetadata): unknown {
const search = config.tools?.web?.search;
return search && typeof search === "object" && metadata.readApiKeyValue
? metadata.readApiKeyValue(search as Record<string, unknown>)
@ -1306,7 +1298,7 @@ function rawKeyValue(
/** Returns the plaintext key string, or undefined for SecretRefs/missing. */
export function resolveExistingKey(
config: OpenClawConfig,
metadata: SearchProviderLegacyConfigMetadata,
metadata: SearchProviderCredentialMetadata,
): string | undefined {
return normalizeSecretInputString(rawKeyValue(config, metadata));
}
@ -1314,13 +1306,13 @@ export function resolveExistingKey(
/** Returns true if a key is configured (plaintext string or SecretRef). */
export function hasExistingKey(
config: OpenClawConfig,
metadata: SearchProviderLegacyConfigMetadata,
metadata: SearchProviderCredentialMetadata,
): boolean {
return hasConfiguredSecretInput(rawKeyValue(config, metadata));
}
/** Build an env-backed SecretRef for a search provider. */
function buildSearchEnvRef(metadata: SearchProviderLegacyConfigMetadata): SecretRef {
function buildSearchEnvRef(metadata: SearchProviderCredentialMetadata): SecretRef {
const envVar =
metadata.envKeys?.find((k) => Boolean(process.env[k]?.trim())) ?? metadata.envKeys?.[0];
if (!envVar) {
@ -1332,7 +1324,7 @@ function buildSearchEnvRef(metadata: SearchProviderLegacyConfigMetadata): Secret
/** Resolve a plaintext key into the appropriate SecretInput based on mode. */
function resolveSearchSecretInput(
provider: SearchProvider,
metadata: SearchProviderLegacyConfigMetadata,
metadata: SearchProviderCredentialMetadata,
key: string,
secretInputMode?: SecretInputMode,
): SecretInput {
@ -1346,7 +1338,7 @@ function resolveSearchSecretInput(
export function applySearchKey(
config: OpenClawConfig,
provider: SearchProvider,
metadata: SearchProviderLegacyConfigMetadata,
metadata: SearchProviderCredentialMetadata,
key: SecretInput,
): OpenClawConfig {
const search = { ...config.tools?.web?.search, provider, enabled: true };

View File

@ -1,6 +1,5 @@
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { isBuiltinWebSearchProviderId } from "../agents/tools/web-search-provider-catalog.js";
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js";
import {
resolveCapabilitySlotConfigPath,
@ -445,7 +444,7 @@ function validateConfigObjectWithPluginsBase(
return;
}
const normalizedProvider = provider.trim().toLowerCase();
if (!normalizedProvider || isBuiltinWebSearchProviderId(normalizedProvider)) {
if (!normalizedProvider) {
return;
}

View File

@ -32,6 +32,17 @@ export type SearchProviderLegacyUiMetadata = {
writeApiKeyValue?: (search: Record<string, unknown>, value: unknown) => void;
};
export type SearchProviderSetupUiMetadata = {
label: string;
hint: string;
envKeys: readonly string[];
placeholder: string;
signupUrl: string;
apiKeyConfigPath: string;
readApiKeyValue?: (search: Record<string, unknown> | undefined) => unknown;
writeApiKeyValue?: (search: Record<string, unknown>, value: unknown) => void;
};
export type SearchProviderFilterSupport = {
country?: boolean;
language?: boolean;
@ -47,6 +58,8 @@ export type SearchProviderLegacyUiMetadataParams = Omit<
provider: string;
};
export type SearchProviderSetupUiMetadataParams = SearchProviderLegacyUiMetadataParams;
const WEB_SEARCH_DOCS_URL = "https://docs.openclaw.ai/tools/web";
export function resolveSearchConfig<T>(search?: Record<string, unknown>): T {
@ -84,6 +97,12 @@ export function createLegacySearchProviderMetadata(
};
}
export function createSearchProviderSetupMetadata(
params: SearchProviderSetupUiMetadataParams,
): SearchProviderSetupUiMetadata {
return createLegacySearchProviderMetadata(params);
}
export function createSearchProviderErrorResult(
error: string,
message: string,

View File

@ -294,7 +294,7 @@ export type SearchProviderContext = {
pluginConfig?: Record<string, unknown>;
};
export type SearchProviderLegacyConfigMetadata = {
export type SearchProviderCredentialMetadata = {
hint?: string;
envKeys?: readonly string[];
placeholder?: string;
@ -304,6 +304,20 @@ export type SearchProviderLegacyConfigMetadata = {
writeApiKeyValue?: (search: Record<string, unknown>, value: unknown) => void;
};
export type SearchProviderInstallMetadata = {
npmSpec: string;
localPath?: string;
defaultChoice?: "npm" | "local";
};
export type SearchProviderSetupMetadata = {
hint?: string;
credentials?: SearchProviderCredentialMetadata;
install?: SearchProviderInstallMetadata;
};
export type SearchProviderLegacyConfigMetadata = SearchProviderCredentialMetadata;
export type SearchProviderRuntimeMetadata = Record<string, unknown>;
export type SearchProviderRuntimeMetadataResolver = (params: {
@ -321,6 +335,7 @@ export type SearchProviderPlugin = {
pluginOwnedExecution?: boolean;
docsUrl?: string;
configFieldOrder?: string[];
setup?: SearchProviderSetupMetadata;
legacyConfig?: SearchProviderLegacyConfigMetadata;
resolveRuntimeMetadata?: SearchProviderRuntimeMetadataResolver;
isAvailable?: (config?: OpenClawConfig) => boolean;

View File

@ -1,18 +1,109 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import * as secretResolve from "./resolve.js";
import { createResolverContext } from "./runtime-shared.js";
const loadOpenClawPlugins = vi.hoisted(() => vi.fn());
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins,
}));
import { resolveRuntimeWebTools } from "./runtime-web-tools.js";
type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity";
function createProviderCredentialMetadata(provider: ProviderUnderTest) {
const readApiKeyValue = (search: Record<string, unknown> | undefined) => {
if (!search) {
return undefined;
}
if (provider === "brave") {
return search.apiKey;
}
const scoped = search[provider];
return typeof scoped === "object" && scoped !== null && !Array.isArray(scoped)
? (scoped as Record<string, unknown>).apiKey
: undefined;
};
const writeApiKeyValue = (search: Record<string, unknown>, value: unknown) => {
if (provider === "brave") {
search.apiKey = value;
return;
}
const current = search[provider];
if (typeof current === "object" && current !== null && !Array.isArray(current)) {
(current as Record<string, unknown>).apiKey = value;
return;
}
search[provider] = { apiKey: value };
};
const envKeys = {
brave: ["BRAVE_API_KEY"],
gemini: ["GEMINI_API_KEY"],
grok: ["XAI_API_KEY"],
kimi: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
perplexity: ["PERPLEXITY_API_KEY"],
}[provider];
const apiKeyConfigPath = {
brave: "tools.web.search.apiKey",
gemini: "tools.web.search.gemini.apiKey",
grok: "tools.web.search.grok.apiKey",
kimi: "tools.web.search.kimi.apiKey",
perplexity: "tools.web.search.perplexity.apiKey",
}[provider];
return {
envKeys,
apiKeyConfigPath,
readApiKeyValue,
writeApiKeyValue,
};
}
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
function withBundledSearchPluginsEnabled(config: OpenClawConfig): OpenClawConfig {
return {
...config,
plugins: {
...config.plugins,
enabled: true,
entries: {
...config.plugins?.entries,
"search-brave": {
...config.plugins?.entries?.["search-brave"],
enabled: true,
},
"search-gemini": {
...config.plugins?.entries?.["search-gemini"],
enabled: true,
},
"search-grok": {
...config.plugins?.entries?.["search-grok"],
enabled: true,
},
"search-kimi": {
...config.plugins?.entries?.["search-kimi"],
enabled: true,
},
"search-perplexity": {
...config.plugins?.entries?.["search-perplexity"],
enabled: true,
},
},
},
};
}
async function runRuntimeWebTools(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) {
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(params.config);
const sourceConfig = structuredClone(withBundledSearchPluginsEnabled(params.config));
const resolvedConfig = structuredClone(sourceConfig);
const context = createResolverContext({
sourceConfig,
env: params.env ?? {},
@ -83,6 +174,28 @@ function expectInactiveFirecrawlSecretRef(params: {
);
}
beforeEach(() => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: (["brave", "gemini", "grok", "kimi", "perplexity"] as const).map(
(provider) => ({
pluginId: `search-${provider}`,
provider: {
id: provider,
name: provider,
setup: {
credentials: createProviderCredentialMetadata(provider),
},
resolveRuntimeMetadata:
provider === "perplexity" ? () => ({ perplexityTransport: "search_api" }) : undefined,
search: async () => ({ content: "ok" }),
},
}),
),
plugins: [],
typedHooks: [],
});
});
describe("runtime web tools resolution", () => {
afterEach(() => {
vi.restoreAllMocks();

View File

@ -1,12 +1,12 @@
import {
BUILTIN_WEB_SEARCH_PROVIDER_IDS,
type BuiltinWebSearchProviderId,
normalizeBuiltinWebSearchProvider,
} from "../agents/tools/web-search-provider-catalog.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import type { SearchProviderLegacyConfigMetadata, SearchProviderPlugin } from "../plugins/types.js";
import type {
SearchProviderCredentialMetadata,
SearchProviderLegacyConfigMetadata,
SearchProviderPlugin,
SearchProviderSetupMetadata,
} from "../plugins/types.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import { secretRefKey } from "./ref-contract.js";
import { resolveSecretRefValues } from "./resolve.js";
@ -17,7 +17,7 @@ import {
type SecretDefaults,
} from "./runtime-shared.js";
type WebSearchProvider = BuiltinWebSearchProviderId;
type WebSearchProvider = string;
type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; // pragma: allowlist secret
type RuntimeWebProviderSource = "configured" | "auto-detect" | "none";
@ -79,14 +79,40 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
function normalizeProvider(value: unknown): WebSearchProvider | undefined {
return normalizeBuiltinWebSearchProvider(value);
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
return normalized || undefined;
}
type RegisteredSearchProviderRuntimeSupport = {
legacyConfig: SearchProviderLegacyConfigMetadata;
setup?: SearchProviderSetupMetadata;
resolveRuntimeMetadata?: SearchProviderPlugin["resolveRuntimeMetadata"];
};
function resolveProviderSetupMetadata(
setup?: SearchProviderSetupMetadata,
legacyConfig?: SearchProviderLegacyConfigMetadata,
): SearchProviderSetupMetadata | undefined {
if (setup) {
return setup;
}
if (!legacyConfig) {
return undefined;
}
return {
hint: legacyConfig.hint,
credentials: legacyConfig,
};
}
function resolveProviderCredentialMetadata(
setup?: SearchProviderSetupMetadata,
): SearchProviderCredentialMetadata | undefined {
return setup?.credentials;
}
function resolveRegisteredSearchProviderMetadata(
config: OpenClawConfig,
): Map<WebSearchProvider, RegisteredSearchProviderRuntimeSupport> {
@ -98,19 +124,11 @@ function resolveRegisteredSearchProviderMetadata(
});
return new Map(
registry.searchProviders
.filter(
(
entry,
): entry is typeof entry & {
provider: typeof entry.provider & { legacyConfig: SearchProviderLegacyConfigMetadata };
} =>
normalizeProvider(entry.provider.id) !== undefined &&
Boolean(entry.provider.legacyConfig),
)
.filter((entry) => normalizeProvider(entry.provider.id) !== undefined)
.map((entry) => [
entry.provider.id as WebSearchProvider,
entry.provider.id,
{
legacyConfig: entry.provider.legacyConfig,
setup: resolveProviderSetupMetadata(entry.provider.setup, entry.provider.legacyConfig),
resolveRuntimeMetadata: entry.provider.resolveRuntimeMetadata,
},
]),
@ -270,7 +288,10 @@ function setResolvedWebSearchApiKey(params: {
const tools = ensureObject(params.resolvedConfig as Record<string, unknown>, "tools");
const web = ensureObject(tools, "web");
const search = ensureObject(web, "search");
params.metadata.legacyConfig.writeApiKeyValue?.(search, params.value);
resolveProviderCredentialMetadata(params.metadata.setup)?.writeApiKeyValue?.(
search,
params.value,
);
}
function setResolvedFirecrawlApiKey(params: {
@ -288,7 +309,9 @@ function envVarsForProvider(
metadataByProvider: Map<WebSearchProvider, RegisteredSearchProviderRuntimeSupport>,
provider: WebSearchProvider,
): string[] {
return [...(metadataByProvider.get(provider)?.legacyConfig.envKeys ?? [])];
return [
...(resolveProviderCredentialMetadata(metadataByProvider.get(provider)?.setup)?.envKeys ?? []),
];
}
function resolveProviderKeyValue(
@ -296,7 +319,9 @@ function resolveProviderKeyValue(
search: Record<string, unknown>,
provider: WebSearchProvider,
): unknown {
return metadataByProvider.get(provider)?.legacyConfig.readApiKeyValue?.(search);
return resolveProviderCredentialMetadata(
metadataByProvider.get(provider)?.setup,
)?.readApiKeyValue?.(search);
}
function providerConfigPath(
@ -304,7 +329,8 @@ function providerConfigPath(
provider: WebSearchProvider,
): string {
return (
metadataByProvider.get(provider)?.legacyConfig.apiKeyConfigPath ?? "tools.web.search.provider"
resolveProviderCredentialMetadata(metadataByProvider.get(provider)?.setup)?.apiKeyConfigPath ??
"tools.web.search.provider"
);
}
@ -339,8 +365,12 @@ export async function resolveRuntimeWebTools(params: {
const rawProvider =
typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : "";
const configuredProvider = normalizeProvider(rawProvider);
const knownProviders = [...searchProviderMetadata.keys()];
const hasConfiguredSelection = Boolean(
configuredProvider && searchProviderMetadata.has(configuredProvider),
);
if (rawProvider && !configuredProvider) {
if (rawProvider && (!configuredProvider || !searchProviderMetadata.has(configuredProvider))) {
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT",
message: `tools.web.search.provider is "${rawProvider}". Falling back to auto-detect precedence.`,
@ -355,15 +385,18 @@ export async function resolveRuntimeWebTools(params: {
});
}
if (configuredProvider) {
if (hasConfiguredSelection && configuredProvider) {
searchMetadata.providerConfigured = configuredProvider;
searchMetadata.providerSource = "configured";
}
if (searchEnabled && search) {
const candidates = configuredProvider
? [configuredProvider]
: [...BUILTIN_WEB_SEARCH_PROVIDER_IDS];
const candidates =
hasConfiguredSelection && configuredProvider
? [configuredProvider]
: knownProviders.filter((provider) =>
Boolean(resolveProviderCredentialMetadata(searchProviderMetadata.get(provider)?.setup)),
);
const unresolvedWithoutFallback: Array<{
provider: WebSearchProvider;
path: string;
@ -410,7 +443,7 @@ export async function resolveRuntimeWebTools(params: {
});
}
if (configuredProvider) {
if (hasConfiguredSelection) {
selectedProvider = provider;
selectedResolution = resolution;
if (resolution.value) {
@ -418,7 +451,7 @@ export async function resolveRuntimeWebTools(params: {
setResolvedWebSearchApiKey({
resolvedConfig: params.resolvedConfig,
provider,
metadata: metadata ?? { legacyConfig: {} },
metadata: metadata ?? {},
value: resolution.value,
});
}
@ -432,7 +465,7 @@ export async function resolveRuntimeWebTools(params: {
setResolvedWebSearchApiKey({
resolvedConfig: params.resolvedConfig,
provider,
metadata: metadata ?? { legacyConfig: {} },
metadata: metadata ?? {},
value: resolution.value,
});
break;
@ -455,7 +488,7 @@ export async function resolveRuntimeWebTools(params: {
throw new Error(`[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK] ${unresolved.reason}`);
};
if (configuredProvider) {
if (hasConfiguredSelection) {
const unresolved = unresolvedWithoutFallback[0];
if (unresolved) {
failUnresolvedSearchNoFallback(unresolved);
@ -479,7 +512,7 @@ export async function resolveRuntimeWebTools(params: {
if (selectedProvider) {
searchMetadata.selectedProvider = selectedProvider;
searchMetadata.selectedProviderKeySource = selectedResolution?.source;
if (!configuredProvider) {
if (!hasConfiguredSelection) {
searchMetadata.providerSource = "auto-detect";
}
const runtimeMetadata = searchProviderMetadata
@ -500,8 +533,8 @@ export async function resolveRuntimeWebTools(params: {
}
}
if (searchEnabled && search && !configuredProvider && searchMetadata.selectedProvider) {
for (const provider of BUILTIN_WEB_SEARCH_PROVIDER_IDS) {
if (searchEnabled && search && !hasConfiguredSelection && searchMetadata.selectedProvider) {
for (const provider of knownProviders) {
if (provider === searchMetadata.selectedProvider) {
continue;
}
@ -517,7 +550,7 @@ export async function resolveRuntimeWebTools(params: {
});
}
} else if (search && !searchEnabled) {
for (const provider of BUILTIN_WEB_SEARCH_PROVIDER_IDS) {
for (const provider of knownProviders) {
const path = providerConfigPath(searchProviderMetadata, provider);
const value = resolveProviderKeyValue(searchProviderMetadata, search, provider);
if (!hasConfiguredSecretRef(value, defaults)) {
@ -531,9 +564,9 @@ export async function resolveRuntimeWebTools(params: {
}
}
if (searchEnabled && search && configuredProvider) {
for (const provider of BUILTIN_WEB_SEARCH_PROVIDER_IDS) {
if (provider === configuredProvider) {
if (searchEnabled && search && hasConfiguredSelection && searchMetadata.providerConfigured) {
for (const provider of knownProviders) {
if (provider === searchMetadata.providerConfigured) {
continue;
}
const path = providerConfigPath(searchProviderMetadata, provider);
@ -544,7 +577,7 @@ export async function resolveRuntimeWebTools(params: {
pushInactiveSurfaceWarning({
context: params.context,
path,
details: `tools.web.search.provider is "${configuredProvider}".`,
details: `tools.web.search.provider is "${searchMetadata.providerConfigured}".`,
});
}
}

View File

@ -388,7 +388,6 @@ describe("finalizeOnboardingWizard", () => {
const noteCalls = (prompter.note as ReturnType<typeof vi.fn>).mock.calls;
const webSearchNote = noteCalls.find((call) => call?.[1] === "Web search");
expect(webSearchNote?.[0]).toContain("plugin-provided provider");
expect(webSearchNote?.[0]).toContain("Source: External plugin");
expect(webSearchNote?.[0]).not.toContain("no API key was found");
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
@ -466,8 +465,6 @@ describe("finalizeOnboardingWizard", () => {
const noteCalls = (prompter.note as ReturnType<typeof vi.fn>).mock.calls;
const webSearchNote = noteCalls.find((call) => call?.[1] === "Web search");
expect(webSearchNote?.[0]).toContain("Active provider: Tavily Search");
expect(webSearchNote?.[0]).toContain(
"Multiple web search providers are configured; the others remain available to switch to later via configure.",
);
expect(webSearchNote?.[0]).toContain("plugin-provided provider");
});
});

View File

@ -486,7 +486,6 @@ export async function finalizeOnboardingWizard(
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
if (webSearchProvider) {
const {
SEARCH_PROVIDER_OPTIONS,
resolveExistingKey,
hasExistingKey,
hasKeyInEnv,
@ -503,43 +502,24 @@ export async function finalizeOnboardingWizard(
options.workspaceDir,
);
const configuredProviderCount = providerEntries.filter((entry) => entry.configured).length;
const builtinEntry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === webSearchProvider);
const label = pickerEntry?.label ?? builtinEntry?.label ?? webSearchProvider;
const sourceLine =
pickerEntry?.kind === "plugin"
? `Source: ${pickerEntry.origin === "bundled" ? "Bundled plugin" : "External plugin"}`
: undefined;
const storedKey = builtinEntry ? resolveExistingKey(nextConfig, builtinEntry.value) : undefined;
const keyConfigured = builtinEntry ? hasExistingKey(nextConfig, builtinEntry.value) : false;
const envAvailable = builtinEntry ? hasKeyInEnv(builtinEntry) : false;
const label = pickerEntry?.label ?? webSearchProvider;
const credentialMetadata = pickerEntry?.setup?.credentials;
const storedKey = credentialMetadata
? resolveExistingKey(nextConfig, credentialMetadata)
: undefined;
const keyConfigured = credentialMetadata
? hasExistingKey(nextConfig, credentialMetadata)
: false;
const envAvailable = credentialMetadata ? hasKeyInEnv(credentialMetadata) : false;
const hasKey = keyConfigured || envAvailable;
const keySource = storedKey
? "API key: stored in config."
: keyConfigured
? "API key: configured via secret reference."
: envAvailable
? `API key: provided via ${builtinEntry?.envKeys.join(" / ")} env var.`
? `API key: provided via ${credentialMetadata?.envKeys?.join(" / ")} env var.`
: undefined;
if (pickerEntry?.kind === "plugin") {
await prompter.note(
[
webSearchEnabled !== false
? "Web search is enabled through a plugin-provided provider."
: "Web search is configured through a plugin-provided provider but currently disabled.",
"",
`Active provider: ${label}`,
...(sourceLine ? [sourceLine] : []),
...(configuredProviderCount > 1
? [
"Multiple web search providers are configured; the others remain available to switch to later via configure.",
]
: []),
"Plugin-managed providers may use plugin config or plugin-specific credentials instead of the built-in API key fields.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
} else if (!builtinEntry) {
if (!credentialMetadata) {
await prompter.note(
[
webSearchEnabled !== false
@ -563,7 +543,6 @@ export async function finalizeOnboardingWizard(
"Web search is enabled, so your agent can look things up online when needed.",
"",
`Active provider: ${label}`,
...(sourceLine ? [sourceLine] : []),
...(configuredProviderCount > 1
? [
"Multiple web search providers are configured; the others remain available to switch to later via configure.",
@ -581,7 +560,7 @@ export async function finalizeOnboardingWizard(
"web_search will not work until a key is added.",
` ${formatCliCommand("openclaw configure --section web")}`,
"",
`Get your key at: ${builtinEntry?.signupUrl ?? "https://docs.openclaw.ai/tools/web"}`,
`Get your key at: ${credentialMetadata.signupUrl ?? "https://docs.openclaw.ai/tools/web"}`,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
@ -600,15 +579,22 @@ export async function finalizeOnboardingWizard(
} else {
// Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without
// an explicit provider. Runtime auto-detects these, so avoid saying "skipped".
const { SEARCH_PROVIDER_OPTIONS, hasExistingKey, hasKeyInEnv } =
const { resolveSearchProviderPickerEntries, hasExistingKey, hasKeyInEnv } =
await import("../commands/onboard-search.js");
const legacyDetected = SEARCH_PROVIDER_OPTIONS.find(
(e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e),
const providerEntries = await resolveSearchProviderPickerEntries(
nextConfig,
options.workspaceDir,
);
if (legacyDetected) {
const detectedEntry = providerEntries.find(
(entry) =>
Boolean(entry.setup?.credentials) &&
(hasExistingKey(nextConfig, entry.setup!.credentials!) ||
hasKeyInEnv(entry.setup!.credentials!)),
);
if (detectedEntry) {
await prompter.note(
[
`Web search is available via ${legacyDetected.label} (auto-detected).`,
`Web search is available via ${detectedEntry.label} (auto-detected).`,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",