diff --git a/extensions/search-brave/src/provider.ts b/extensions/search-brave/src/provider.ts index 888615240d3..50bcc17c709 100644 --- a/extensions/search-brave/src/provider.ts +++ b/extensions/search-brave/src/provider.ts @@ -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( diff --git a/extensions/search-gemini/src/provider.ts b/extensions/search-gemini/src/provider.ts index 3a7f50b6c59..536e75ace03 100644 --- a/extensions/search-gemini/src/provider.ts +++ b/extensions/search-gemini/src/provider.ts @@ -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( diff --git a/extensions/search-grok/src/provider.ts b/extensions/search-grok/src/provider.ts index e697b001a5d..14fffa8b863 100644 --- a/extensions/search-grok/src/provider.ts +++ b/extensions/search-grok/src/provider.ts @@ -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( diff --git a/extensions/search-kimi/src/provider.ts b/extensions/search-kimi/src/provider.ts index 0d62a6f78da..6512980b582 100644 --- a/extensions/search-kimi/src/provider.ts +++ b/extensions/search-kimi/src/provider.ts @@ -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( diff --git a/extensions/search-perplexity/src/provider.ts b/extensions/search-perplexity/src/provider.ts index 771afceda73..9df537e2503 100644 --- a/extensions/search-perplexity/src/provider.ts +++ b/extensions/search-perplexity/src/provider.ts @@ -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( diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index ee6fd74c38d..572c7c85fa9 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -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"; } diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index d614a61fdfb..7d2d2e18fd4 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -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( diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 3168846960c..1a34674c367 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -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).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", + }), + ]), + ); }); }); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index 823669d5811..7746bc63a3c 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -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; configUiHints?: Record; - 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; configUiHints?: Record; 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) @@ -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 }; diff --git a/src/config/validation.ts b/src/config/validation.ts index a5b7a0e32b5..b034869966e 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -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; } diff --git a/src/plugin-sdk/web-search.ts b/src/plugin-sdk/web-search.ts index b6f4908bcad..a55dd592181 100644 --- a/src/plugin-sdk/web-search.ts +++ b/src/plugin-sdk/web-search.ts @@ -32,6 +32,17 @@ export type SearchProviderLegacyUiMetadata = { writeApiKeyValue?: (search: Record, value: unknown) => void; }; +export type SearchProviderSetupUiMetadata = { + label: string; + hint: string; + envKeys: readonly string[]; + placeholder: string; + signupUrl: string; + apiKeyConfigPath: string; + readApiKeyValue?: (search: Record | undefined) => unknown; + writeApiKeyValue?: (search: Record, 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(search?: Record): T { @@ -84,6 +97,12 @@ export function createLegacySearchProviderMetadata( }; } +export function createSearchProviderSetupMetadata( + params: SearchProviderSetupUiMetadataParams, +): SearchProviderSetupUiMetadata { + return createLegacySearchProviderMetadata(params); +} + export function createSearchProviderErrorResult( error: string, message: string, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index da095ca332b..0e953458d6d 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -294,7 +294,7 @@ export type SearchProviderContext = { pluginConfig?: Record; }; -export type SearchProviderLegacyConfigMetadata = { +export type SearchProviderCredentialMetadata = { hint?: string; envKeys?: readonly string[]; placeholder?: string; @@ -304,6 +304,20 @@ export type SearchProviderLegacyConfigMetadata = { writeApiKeyValue?: (search: Record, 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; 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; diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index 57e3e955066..fe05d522fa2 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -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 | 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).apiKey + : undefined; + }; + + const writeApiKeyValue = (search: Record, 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).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(); diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 0e6124f644a..8cbe4368f9f 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -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 { } 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 { @@ -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, "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, 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, 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}".`, }); } } diff --git a/src/wizard/onboarding.finalize.test.ts b/src/wizard/onboarding.finalize.test.ts index fbb7196f4d3..10257304f3f 100644 --- a/src/wizard/onboarding.finalize.test.ts +++ b/src/wizard/onboarding.finalize.test.ts @@ -388,7 +388,6 @@ describe("finalizeOnboardingWizard", () => { const noteCalls = (prompter.note as ReturnType).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).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"); }); }); diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index d2e62a3e89b..25acd4d37a6 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -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",