Plugins: decouple bundled web search discovery

This commit is contained in:
Gustavo Madeira Santana
2026-03-16 12:19:32 +00:00
parent c08f2aa21a
commit b7f99a57bf
7 changed files with 255 additions and 186 deletions

View File

@@ -0,0 +1,14 @@
import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js";
import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js";
import { applyPrimaryModel } from "../commands/model-picker.js";
import { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js";
import { applyAuthProfileConfig } from "../commands/onboard-auth.js";
export {
applyAuthProfileConfig,
applyPrimaryModel,
buildApiKeyCredential,
ensureApiKeyFromOptionEnvOrPrompt,
normalizeApiKeyInput,
validateApiKeyInput,
};

View File

@@ -1,9 +1,4 @@
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js";
import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js";
import { applyPrimaryModel } from "../commands/model-picker.js";
import { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js";
import { applyAuthProfileConfig } from "../commands/onboard-auth.js";
import { upsertAuthProfile } from "../agents/auth-profiles/profiles.js";
import type { OpenClawConfig } from "../config/config.js";
import type { SecretInput } from "../config/types.secrets.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
@@ -34,6 +29,15 @@ type ProviderApiKeyAuthMethodOptions = {
applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig;
};
let providerApiKeyAuthRuntimePromise:
| Promise<typeof import("./provider-api-key-auth.runtime.js")>
| undefined;
function loadProviderApiKeyAuthRuntime() {
providerApiKeyAuthRuntimePromise ??= import("./provider-api-key-auth.runtime.js");
return providerApiKeyAuthRuntimePromise;
}
function resolveStringOption(opts: Record<string, unknown> | undefined, optionKey: string) {
return normalizeOptionalSecretInput(opts?.[optionKey]);
}
@@ -56,13 +60,14 @@ function resolveProfileIds(params: {
return [resolveProfileId(params)];
}
function applyApiKeyConfig(params: {
async function applyApiKeyConfig(params: {
ctx: ProviderAuthMethodNonInteractiveContext;
providerId: string;
profileIds: string[];
defaultModel?: string;
applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig;
}) {
const { applyAuthProfileConfig, applyPrimaryModel } = await loadProviderApiKeyAuthRuntime();
let next = params.ctx.config;
for (const profileId of params.profileIds) {
next = applyAuthProfileConfig(next, {
@@ -92,6 +97,12 @@ export function createProviderApiKeyAuthMethod(
let capturedSecretInput: SecretInput | undefined;
let capturedCredential = false;
let capturedMode: "plaintext" | "ref" | undefined;
const {
buildApiKeyCredential,
ensureApiKeyFromOptionEnvOrPrompt,
normalizeApiKeyInput,
validateApiKeyInput,
} = await loadProviderApiKeyAuthRuntime();
await ensureApiKeyFromOptionEnvOrPrompt({
token: flagValue ?? normalizeOptionalSecretInput(ctx.opts?.token),
@@ -171,7 +182,7 @@ export function createProviderApiKeyAuthMethod(
}
}
return applyApiKeyConfig({
return await applyApiKeyConfig({
ctx,
providerId: params.providerId,
profileIds,

View File

@@ -1,64 +1,22 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { resolvePluginWebSearchProviders } from "./web-search-providers.js";
const loadOpenClawPluginsMock = vi.fn();
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args),
}));
describe("resolvePluginWebSearchProviders", () => {
beforeEach(() => {
loadOpenClawPluginsMock.mockReset();
loadOpenClawPluginsMock.mockReturnValue({
webSearchProviders: [
{
pluginId: "google",
provider: {
id: "gemini",
label: "Gemini",
hint: "hint",
envVars: ["GEMINI_API_KEY"],
placeholder: "AIza...",
signupUrl: "https://example.com",
autoDetectOrder: 20,
},
},
{
pluginId: "brave",
provider: {
id: "brave",
label: "Brave",
hint: "hint",
envVars: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://example.com",
autoDetectOrder: 10,
},
},
],
});
});
it("returns bundled providers in auto-detect order", () => {
const providers = resolvePluginWebSearchProviders({});
it("forwards an explicit env to plugin loading", () => {
const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv;
const providers = resolvePluginWebSearchProviders({
workspaceDir: "/workspace/explicit",
env,
});
expect(providers.map((provider) => provider.id)).toEqual(["brave", "gemini"]);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/workspace/explicit",
env,
}),
);
expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([
"brave:brave",
"google:gemini",
"xai:grok",
"moonshot:kimi",
"perplexity:perplexity",
"firecrawl:firecrawl",
]);
});
it("can augment restrictive allowlists for bundled compatibility", () => {
resolvePluginWebSearchProviders({
const providers = resolvePluginWebSearchProviders({
config: {
plugins: {
allow: ["openrouter"],
@@ -67,49 +25,30 @@ describe("resolvePluginWebSearchProviders", () => {
bundledAllowlistCompat: true,
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining(["openrouter", "brave", "perplexity"]),
}),
}),
}),
);
expect(providers.map((provider) => provider.pluginId)).toEqual([
"brave",
"google",
"xai",
"moonshot",
"perplexity",
"firecrawl",
]);
});
it("auto-enables bundled web search provider plugins when entries are missing", () => {
resolvePluginWebSearchProviders({
it("does not return bundled providers excluded by a restrictive allowlist without compat", () => {
const providers = resolvePluginWebSearchProviders({
config: {
plugins: {
entries: {
openrouter: { enabled: true },
},
allow: ["openrouter"],
},
},
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.objectContaining({
openrouter: { enabled: true },
brave: { enabled: true },
firecrawl: { enabled: true },
google: { enabled: true },
moonshot: { enabled: true },
perplexity: { enabled: true },
xai: { enabled: true },
}),
}),
}),
}),
);
expect(providers).toEqual([]);
});
it("preserves explicit bundled provider entry state", () => {
resolvePluginWebSearchProviders({
const providers = resolvePluginWebSearchProviders({
config: {
plugins: {
entries: {
@@ -119,16 +58,18 @@ describe("resolvePluginWebSearchProviders", () => {
},
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.objectContaining({
perplexity: { enabled: false },
}),
}),
}),
}),
);
expect(providers.map((provider) => provider.pluginId)).not.toContain("perplexity");
});
it("returns no providers when plugins are globally disabled", () => {
const providers = resolvePluginWebSearchProviders({
config: {
plugins: {
enabled: false,
},
},
});
expect(providers).toEqual([]);
});
});

View File

@@ -1,14 +1,19 @@
import { createSubsystemLogger } from "../logging/subsystem.js";
import { createFirecrawlWebSearchProvider } from "../../extensions/firecrawl/src/firecrawl-search-provider.js";
import {
createPluginBackedWebSearchProvider,
getScopedCredentialValue,
getTopLevelCredentialValue,
setScopedCredentialValue,
setTopLevelCredentialValue,
} from "../agents/tools/web-search-plugin-factory.js";
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
} from "./bundled-compat.js";
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import type { PluginLoadOptions } from "./loader.js";
import type { WebSearchProviderPlugin } from "./types.js";
const log = createSubsystemLogger("plugins");
const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [
"brave",
"firecrawl",
@@ -18,6 +23,92 @@ const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [
"xai",
] as const;
const BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY = [
{
pluginId: "brave",
provider: createPluginBackedWebSearchProvider({
id: "brave",
label: "Brave Search",
hint: "Structured results · country/language/time filters",
envVars: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://brave.com/search/api/",
docsUrl: "https://docs.openclaw.ai/brave-search",
autoDetectOrder: 10,
getCredentialValue: getTopLevelCredentialValue,
setCredentialValue: setTopLevelCredentialValue,
}),
},
{
pluginId: "google",
provider: createPluginBackedWebSearchProvider({
id: "gemini",
label: "Gemini (Google Search)",
hint: "Google Search grounding · AI-synthesized",
envVars: ["GEMINI_API_KEY"],
placeholder: "AIza...",
signupUrl: "https://aistudio.google.com/apikey",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 20,
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "gemini", value),
}),
},
{
pluginId: "xai",
provider: createPluginBackedWebSearchProvider({
id: "grok",
label: "Grok (xAI)",
hint: "xAI web-grounded responses",
envVars: ["XAI_API_KEY"],
placeholder: "xai-...",
signupUrl: "https://console.x.ai/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 30,
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "grok"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "grok", value),
}),
},
{
pluginId: "moonshot",
provider: createPluginBackedWebSearchProvider({
id: "kimi",
label: "Kimi (Moonshot)",
hint: "Moonshot web search",
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
placeholder: "sk-...",
signupUrl: "https://platform.moonshot.cn/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 40,
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "kimi", value),
}),
},
{
pluginId: "perplexity",
provider: createPluginBackedWebSearchProvider({
id: "perplexity",
label: "Perplexity Search",
hint: "Structured results · domain/country/language/time filters",
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
placeholder: "pplx-...",
signupUrl: "https://www.perplexity.ai/settings/api",
docsUrl: "https://docs.openclaw.ai/perplexity",
autoDetectOrder: 50,
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "perplexity", value),
}),
},
{
pluginId: "firecrawl",
provider: createFirecrawlWebSearchProvider(),
},
] as const;
export function resolvePluginWebSearchProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
@@ -34,17 +125,17 @@ export function resolvePluginWebSearchProviders(params: {
config: allowlistCompat,
pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS,
});
const registry = loadOpenClawPlugins({
config,
workspaceDir: params.workspaceDir,
env: params.env,
logger: createPluginLoaderLogger(log),
activate: false,
cache: false,
onlyPluginIds: [...BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS],
});
const normalizedPlugins = normalizePluginsConfig(config?.plugins);
return registry.webSearchProviders
return BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY.filter(
({ pluginId }) =>
resolveEffectiveEnableState({
id: pluginId,
origin: "bundled",
config: normalizedPlugins,
rootConfig: config,
}).enabled,
)
.map((entry) => ({
...entry.provider,
pluginId: entry.pluginId,