mirror of https://github.com/openclaw/openclaw.git
refactor: unify search provider onboarding metadata
This commit is contained in:
parent
98c5c04608
commit
74b5c2e875
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}".`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue