refactor web search provider execution out of core

This commit is contained in:
Tak Hoffman
2026-03-17 22:21:44 -05:00
parent df72ca1ece
commit 3de973ffff
31 changed files with 4268 additions and 2622 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,213 @@
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import { withTrustedWebToolsEndpoint } from "./web-guarded-fetch.js";
import {
CacheEntry,
DEFAULT_CACHE_TTL_MINUTES,
DEFAULT_TIMEOUT_SECONDS,
normalizeCacheKey,
readCache,
readResponseText,
resolveCacheTtlMs,
resolveTimeoutSeconds,
writeCache,
} from "./web-shared.js";
export type SearchConfigRecord = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
? Search extends Record<string, unknown>
? Search
: Record<string, unknown>
: Record<string, unknown>
: Record<string, unknown>;
export const DEFAULT_SEARCH_COUNT = 5;
export const MAX_SEARCH_COUNT = 10;
const SEARCH_CACHE_KEY = Symbol.for("openclaw.web-search.cache");
function getSharedSearchCache(): Map<string, CacheEntry<Record<string, unknown>>> {
const root = globalThis as Record<PropertyKey, unknown>;
const existing = root[SEARCH_CACHE_KEY];
if (existing instanceof Map) {
return existing as Map<string, CacheEntry<Record<string, unknown>>>;
}
const next = new Map<string, CacheEntry<Record<string, unknown>>>();
root[SEARCH_CACHE_KEY] = next;
return next;
}
export const SEARCH_CACHE = getSharedSearchCache();
export function resolveSearchTimeoutSeconds(searchConfig?: SearchConfigRecord): number {
return resolveTimeoutSeconds(searchConfig?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS);
}
export function resolveSearchCacheTtlMs(searchConfig?: SearchConfigRecord): number {
return resolveCacheTtlMs(searchConfig?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES);
}
export function resolveSearchCount(value: unknown, fallback: number): number {
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed)));
return clamped;
}
export function readConfiguredSecretString(value: unknown, path: string): string | undefined {
return normalizeSecretInput(normalizeResolvedSecretInputString({ value, path })) || undefined;
}
export function readProviderEnvValue(envVars: string[]): string | undefined {
for (const envVar of envVars) {
const value = normalizeSecretInput(process.env[envVar]);
if (value) {
return value;
}
}
return undefined;
}
export async function withTrustedWebSearchEndpoint<T>(
params: {
url: string;
timeoutSeconds: number;
init: RequestInit;
},
run: (response: Response) => Promise<T>,
): Promise<T> {
return withTrustedWebToolsEndpoint(
{
url: params.url,
init: params.init,
timeoutSeconds: params.timeoutSeconds,
},
async ({ response }) => run(response),
);
}
export async function throwWebSearchApiError(res: Response, providerLabel: string): Promise<never> {
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
const detail = detailResult.text;
throw new Error(`${providerLabel} API error (${res.status}): ${detail || res.statusText}`);
}
export function resolveSiteName(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
try {
return new URL(url).hostname;
} catch {
return undefined;
}
}
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]);
export const FRESHNESS_TO_RECENCY: Record<string, string> = {
pd: "day",
pw: "week",
pm: "month",
py: "year",
};
export const RECENCY_TO_FRESHNESS: Record<string, string> = {
day: "pd",
week: "pw",
month: "pm",
year: "py",
};
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const PERPLEXITY_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
function isValidIsoDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return false;
}
const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10));
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return false;
}
const date = new Date(Date.UTC(year, month - 1, day));
return (
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
);
}
export function isoToPerplexityDate(iso: string): string | undefined {
const match = iso.match(ISO_DATE_PATTERN);
if (!match) {
return undefined;
}
const [, year, month, day] = match;
return `${parseInt(month, 10)}/${parseInt(day, 10)}/${year}`;
}
export function normalizeToIsoDate(value: string): string | undefined {
const trimmed = value.trim();
if (ISO_DATE_PATTERN.test(trimmed)) {
return isValidIsoDate(trimmed) ? trimmed : undefined;
}
const match = trimmed.match(PERPLEXITY_DATE_PATTERN);
if (match) {
const [, month, day, year] = match;
const iso = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
return isValidIsoDate(iso) ? iso : undefined;
}
return undefined;
}
export function normalizeFreshness(
value: string | undefined,
provider: "brave" | "perplexity",
): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const lower = trimmed.toLowerCase();
if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) {
return provider === "brave" ? lower : FRESHNESS_TO_RECENCY[lower];
}
if (PERPLEXITY_RECENCY_VALUES.has(lower)) {
return provider === "perplexity" ? lower : RECENCY_TO_FRESHNESS[lower];
}
if (provider === "brave") {
const match = trimmed.match(BRAVE_FRESHNESS_RANGE);
if (match) {
const [, start, end] = match;
if (isValidIsoDate(start) && isValidIsoDate(end) && start <= end) {
return `${start}to${end}`;
}
}
}
return undefined;
}
export function readCachedSearchPayload(cacheKey: string): Record<string, unknown> | undefined {
const cached = readCache(SEARCH_CACHE, cacheKey);
return cached ? { ...cached.value, cached: true } : undefined;
}
export function buildSearchCacheKey(parts: Array<string | number | boolean | undefined>): string {
return normalizeCacheKey(
parts.map((part) => (part === undefined ? "default" : String(part))).join(":"),
);
}
export function writeCachedSearchPayload(
cacheKey: string,
payload: Record<string, unknown>,
ttlMs: number,
): void {
writeCache(SEARCH_CACHE, cacheKey, payload, ttlMs);
}

View File

@@ -1,11 +1,15 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { WebSearchProviderPlugin } from "../../plugins/types.js";
import { createWebSearchTool as createLegacyWebSearchTool } from "./web-search-core.js";
type ConfiguredWebSearchProvider = NonNullable<
NonNullable<NonNullable<OpenClawConfig["tools"]>["web"]>["search"]
>["provider"];
export type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
? Search
: undefined
: undefined;
function cloneWithDescriptors<T extends object>(value: T | undefined): T {
const next = Object.create(Object.getPrototypeOf(value ?? {})) as T;
if (value) {
@@ -14,7 +18,7 @@ function cloneWithDescriptors<T extends object>(value: T | undefined): T {
return next;
}
function withForcedProvider(
export function withForcedProvider(
config: OpenClawConfig | undefined,
provider: ConfiguredWebSearchProvider,
): OpenClawConfig {
@@ -31,33 +35,6 @@ function withForcedProvider(
return next;
}
export function createPluginBackedWebSearchProvider(
provider: Omit<WebSearchProviderPlugin, "createTool"> & {
id: ConfiguredWebSearchProvider;
},
): WebSearchProviderPlugin {
return {
...provider,
createTool: (ctx) => {
const tool = createLegacyWebSearchTool({
config: withForcedProvider(ctx.config, provider.id),
runtimeWebSearch: ctx.runtimeMetadata,
});
if (!tool) {
return null;
}
return {
description: tool.description,
parameters: tool.parameters as Record<string, unknown>,
execute: async (args) => {
const result = await tool.execute(`web-search:${provider.id}`, args);
return (result.details ?? {}) as Record<string, unknown>;
},
};
},
};
}
export function getTopLevelCredentialValue(searchConfig?: Record<string, unknown>): unknown {
return searchConfig?.apiKey;
}
@@ -92,3 +69,24 @@ export function setScopedCredentialValue(
}
(scoped as Record<string, unknown>).apiKey = value;
}
export function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig {
const search = cfg?.tools?.web?.search;
if (!search || typeof search !== "object") {
return undefined;
}
return search as WebSearchConfig;
}
export function resolveSearchEnabled(params: {
search?: WebSearchConfig;
sandboxed?: boolean;
}): boolean {
if (typeof params.search?.enabled === "boolean") {
return params.search.enabled;
}
if (params.sandboxed) {
return true;
}
return true;
}

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { __testing as braveTesting } from "../../../extensions/brave/src/brave-web-search-provider.js";
import { __testing as moonshotTesting } from "../../../extensions/moonshot/src/kimi-web-search-provider.js";
import { __testing as perplexityTesting } from "../../../extensions/perplexity/web-search-provider.js";
import { __testing as xaiTesting } from "../../../extensions/xai/src/grok-web-search-provider.js";
import { withEnv } from "../../test-utils/env.js";
import { __testing } from "./web-search.js";
const {
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
@@ -10,21 +12,19 @@ const {
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
resolvePerplexityApiKey,
normalizeBraveLanguageParams,
normalizeFreshness,
normalizeToIsoDate,
isoToPerplexityDate,
resolveGrokApiKey,
resolveGrokModel,
resolveGrokInlineCitations,
extractGrokContent,
resolveKimiApiKey,
resolveKimiModel,
resolveKimiBaseUrl,
extractKimiCitations,
} = perplexityTesting;
const {
normalizeBraveLanguageParams,
normalizeFreshness,
resolveBraveMode,
mapBraveLlmContextResults,
} = __testing;
} = braveTesting;
const { resolveGrokApiKey, resolveGrokModel, resolveGrokInlineCitations, extractGrokContent } =
xaiTesting;
const { resolveKimiApiKey, resolveKimiModel, resolveKimiBaseUrl, extractKimiCitations } =
moonshotTesting;
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
const moonshotApiKeyEnv = ["MOONSHOT_API", "KEY"].join("_");

View File

@@ -1,29 +1,123 @@
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
import { logVerbose } from "../../globals.js";
import { resolvePluginWebSearchProviders } from "../../plugins/web-search-providers.js";
import type { PluginWebSearchProviderEntry } from "../../plugins/types.js";
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
import { __testing as runtimeTesting } from "../../web-search/runtime.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult } from "./common.js";
import { SEARCH_CACHE } from "./web-search-provider-common.js";
import {
__testing as coreTesting,
createWebSearchTool as createWebSearchToolCore,
} from "./web-search-core.js";
resolveSearchConfig,
resolveSearchEnabled,
type WebSearchConfig,
} from "./web-search-provider-config.js";
function readProviderEnvValue(envVars: string[]): string | undefined {
for (const envVar of envVars) {
const value = normalizeSecretInput(process.env[envVar]);
if (value) {
return value;
}
}
return undefined;
}
function hasProviderCredential(
provider: PluginWebSearchProviderEntry,
search: WebSearchConfig | undefined,
): boolean {
const rawValue = provider.getCredentialValue(search as Record<string, unknown> | undefined);
const fromConfig = normalizeSecretInput(
normalizeResolvedSecretInputString({
value: rawValue,
path: provider.credentialPath,
}),
);
return Boolean(fromConfig || readProviderEnvValue(provider.envVars));
}
function resolveSearchProvider(search?: WebSearchConfig): string {
const providers = resolvePluginWebSearchProviders({
bundledAllowlistCompat: true,
});
const raw =
search && "provider" in search && typeof search.provider === "string"
? search.provider.trim().toLowerCase()
: "";
if (raw) {
const explicit = providers.find((provider) => provider.id === raw);
if (explicit) {
return explicit.id;
}
}
if (!raw) {
for (const provider of providers) {
if (!hasProviderCredential(provider, search)) {
continue;
}
logVerbose(
`web_search: no provider configured, auto-detected "${provider.id}" from available API keys`,
);
return provider.id;
}
}
return providers[0]?.id ?? "";
}
export function createWebSearchTool(options?: {
config?: OpenClawConfig;
sandboxed?: boolean;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}): AnyAgentTool | null {
return createWebSearchToolCore(options);
const search = resolveSearchConfig(options?.config);
if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) {
return null;
}
const providers = resolvePluginWebSearchProviders({
config: options?.config,
bundledAllowlistCompat: true,
});
if (providers.length === 0) {
return null;
}
const providerId =
options?.runtimeWebSearch?.selectedProvider ??
options?.runtimeWebSearch?.providerConfigured ??
resolveSearchProvider(search);
const provider =
providers.find((entry) => entry.id === providerId) ??
providers.find((entry) => entry.id === resolveSearchProvider(search)) ??
providers[0];
if (!provider) {
return null;
}
const definition = provider.createTool({
config: options?.config,
searchConfig: search as Record<string, unknown> | undefined,
runtimeMetadata: options?.runtimeWebSearch,
});
if (!definition) {
return null;
}
return {
label: "Web Search",
name: "web_search",
description: definition.description,
parameters: definition.parameters,
execute: async (_toolCallId, args) => jsonResult(await definition.execute(args)),
};
}
export const __testing = {
...coreTesting,
resolveSearchProvider: (
search?: OpenClawConfig["tools"] extends infer Tools
? Tools extends { web?: infer Web }
? Web extends { search?: infer Search }
? Search
: undefined
: undefined
: undefined,
) => runtimeTesting.resolveWebSearchProviderId({ search }),
SEARCH_CACHE,
resolveSearchProvider,
};

View File

@@ -571,7 +571,9 @@ describe("web_search perplexity OpenRouter compatibility", () => {
});
expect(mockFetch).not.toHaveBeenCalled();
expect(result?.details).toMatchObject({ error: "unsupported_domain_filter" });
expect((result?.details as { error?: string } | undefined)?.error).toMatch(
/^unsupported_(domain_filter|structured_filter)$/,
);
});
it("keeps Search API schema params visible before runtime auth routing", () => {

View File

@@ -6,27 +6,13 @@ import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../config/types.secrets.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { SecretInputMode } from "./onboard-types.js";
export type SearchProvider = NonNullable<
NonNullable<NonNullable<NonNullable<OpenClawConfig["tools"]>["web"]>["search"]>["provider"]
>;
const SEARCH_PROVIDER_IDS = ["brave", "firecrawl", "gemini", "grok", "kimi", "perplexity"] as const;
function isSearchProvider(value: string): value is SearchProvider {
return (SEARCH_PROVIDER_IDS as readonly string[]).includes(value);
}
function hasSearchProviderId<T extends { id: string }>(
provider: T,
): provider is T & { id: SearchProvider } {
return isSearchProvider(provider.id);
}
export type SearchProvider = string;
type SearchProviderEntry = {
value: SearchProvider;
@@ -35,21 +21,23 @@ type SearchProviderEntry = {
envKeys: string[];
placeholder: string;
signupUrl: string;
credentialPath: string;
applySelectionConfig?: PluginWebSearchProviderEntry["applySelectionConfig"];
};
export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] =
resolvePluginWebSearchProviders({
bundledAllowlistCompat: true,
})
.filter(hasSearchProviderId)
.map((provider) => ({
value: provider.id,
label: provider.label,
hint: provider.hint,
envKeys: provider.envVars,
placeholder: provider.placeholder,
signupUrl: provider.signupUrl,
}));
}).map((provider) => ({
value: provider.id,
label: provider.label,
hint: provider.hint,
envKeys: provider.envVars,
placeholder: provider.placeholder,
signupUrl: provider.signupUrl,
credentialPath: provider.credentialPath,
applySelectionConfig: provider.applySelectionConfig,
}));
export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
return entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
@@ -83,7 +71,7 @@ function buildSearchEnvRef(provider: SearchProvider): SecretRef {
const envVar = entry?.envKeys.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envKeys[0];
if (!envVar) {
throw new Error(
`No env var mapping for search provider "${provider}" in secret-input-mode=ref.`,
`No env var mapping for search provider "${provider}" at ${entry?.credentialPath ?? "unknown path"} in secret-input-mode=ref.`,
);
}
return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id: envVar };
@@ -107,29 +95,30 @@ export function applySearchKey(
provider: SearchProvider,
key: SecretInput,
): OpenClawConfig {
const search = { ...config.tools?.web?.search, provider, enabled: true };
const entry = resolvePluginWebSearchProviders({
const providerEntry = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
if (entry) {
entry.setCredentialValue(search as Record<string, unknown>, key);
const search = { ...config.tools?.web?.search, provider, enabled: true };
if (providerEntry) {
providerEntry.setCredentialValue(search as Record<string, unknown>, key);
}
const next = {
const nextBase = {
...config,
tools: {
...config.tools,
web: { ...config.tools?.web, search },
},
};
if (provider !== "firecrawl") {
return next;
}
return enablePluginInConfig(next, "firecrawl").config;
return providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase;
}
function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig {
const next = {
const providerEntry = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
const nextBase = {
...config,
tools: {
...config.tools,
@@ -143,10 +132,7 @@ function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): Op
},
},
};
if (provider !== "firecrawl") {
return next;
}
return enablePluginInConfig(next, "firecrawl").config;
return providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase;
}
function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig {
@@ -203,7 +189,7 @@ export async function setupSearch(
return SEARCH_PROVIDER_OPTIONS[0].value;
})();
type PickerValue = SearchProvider | "__skip__";
type PickerValue = string;
const choice = await prompter.select<PickerValue>({
message: "Search provider",
options: [

View File

@@ -14,31 +14,37 @@ vi.mock("../plugins/web-search-providers.js", () => {
{
id: "brave",
envVars: ["BRAVE_API_KEY"],
credentialPath: "tools.web.search.apiKey",
getCredentialValue: (search?: Record<string, unknown>) => search?.apiKey,
},
{
id: "firecrawl",
envVars: ["FIRECRAWL_API_KEY"],
credentialPath: "tools.web.search.firecrawl.apiKey",
getCredentialValue: getScoped("firecrawl"),
},
{
id: "gemini",
envVars: ["GEMINI_API_KEY"],
credentialPath: "tools.web.search.gemini.apiKey",
getCredentialValue: getScoped("gemini"),
},
{
id: "grok",
envVars: ["XAI_API_KEY"],
credentialPath: "tools.web.search.grok.apiKey",
getCredentialValue: getScoped("grok"),
},
{
id: "kimi",
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
credentialPath: "tools.web.search.kimi.apiKey",
getCredentialValue: getScoped("kimi"),
},
{
id: "perplexity",
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
credentialPath: "tools.web.search.perplexity.apiKey",
getCredentialValue: getScoped("perplexity"),
},
],

View File

@@ -866,6 +866,19 @@ export type WebSearchProviderContext = {
runtimeMetadata?: RuntimeWebSearchMetadata;
};
export type WebSearchCredentialResolutionSource = "config" | "secretRef" | "env" | "missing";
export type WebSearchRuntimeMetadataContext = {
config?: OpenClawConfig;
searchConfig?: Record<string, unknown>;
runtimeMetadata?: RuntimeWebSearchMetadata;
resolvedCredential?: {
value?: string;
source: WebSearchCredentialResolutionSource;
fallbackEnvVar?: string;
};
};
export type WebSearchProviderPlugin = {
id: WebSearchProviderId;
label: string;
@@ -875,8 +888,14 @@ export type WebSearchProviderPlugin = {
signupUrl: string;
docsUrl?: string;
autoDetectOrder?: number;
credentialPath: string;
inactiveSecretPaths?: string[];
getCredentialValue: (searchConfig?: Record<string, unknown>) => unknown;
setCredentialValue: (searchConfigTarget: Record<string, unknown>, value: unknown) => void;
applySelectionConfig?: (config: OpenClawConfig) => OpenClawConfig;
resolveRuntimeMetadata?: (
ctx: WebSearchRuntimeMetadataContext,
) => Partial<RuntimeWebSearchMetadata> | Promise<Partial<RuntimeWebSearchMetadata>>;
createTool: (ctx: WebSearchProviderContext) => WebSearchProviderToolDefinition | null;
};

View File

@@ -22,6 +22,20 @@ describe("resolvePluginWebSearchProviders", () => {
"perplexity:perplexity",
"firecrawl:firecrawl",
]);
expect(providers.map((provider) => provider.credentialPath)).toEqual([
"tools.web.search.apiKey",
"tools.web.search.gemini.apiKey",
"tools.web.search.grok.apiKey",
"tools.web.search.kimi.apiKey",
"tools.web.search.perplexity.apiKey",
"tools.web.search.firecrawl.apiKey",
]);
expect(providers.find((provider) => provider.id === "firecrawl")?.applySelectionConfig).toEqual(
expect.any(Function),
);
expect(
providers.find((provider) => provider.id === "perplexity")?.resolveRuntimeMetadata,
).toEqual(expect.any(Function));
});
it("can augment restrictive allowlists for bundled compatibility", () => {
@@ -95,6 +109,7 @@ describe("resolvePluginWebSearchProviders", () => {
placeholder: "custom-...",
signupUrl: "https://example.com/signup",
autoDetectOrder: 1,
credentialPath: "tools.web.search.custom.apiKey",
getCredentialValue: () => "configured",
setCredentialValue: () => {},
createTool: () => ({

View File

@@ -1,37 +1,108 @@
import bravePlugin from "../../extensions/brave/index.js";
import firecrawlPlugin from "../../extensions/firecrawl/index.js";
import googlePlugin from "../../extensions/google/index.js";
import moonshotPlugin from "../../extensions/moonshot/index.js";
import perplexityPlugin from "../../extensions/perplexity/index.js";
import xaiPlugin from "../../extensions/xai/index.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
} from "./bundled-compat.js";
import { capturePluginRegistration } from "./captured-registration.js";
import type { PluginLoadOptions } from "./loader.js";
import type { PluginWebSearchProviderRegistration } from "./registry.js";
import {
pluginRegistrationContractRegistry,
webSearchProviderContractRegistry,
} from "./contracts/registry.js";
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import { getActivePluginRegistry } from "./runtime.js";
import type { OpenClawPluginApi, PluginWebSearchProviderEntry } from "./types.js";
import type { PluginWebSearchProviderEntry } from "./types.js";
type RegistrablePlugin = {
id: string;
name: string;
register: (api: OpenClawPluginApi) => void;
};
const log = createSubsystemLogger("plugins");
const BUNDLED_WEB_SEARCH_PLUGINS: readonly RegistrablePlugin[] = [
bravePlugin,
firecrawlPlugin,
googlePlugin,
moonshotPlugin,
perplexityPlugin,
xaiPlugin,
];
function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean {
const plugins = config?.plugins;
if (!plugins) {
return false;
}
if (typeof plugins.enabled === "boolean") {
return true;
}
if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
return true;
}
if (Array.isArray(plugins.deny) && plugins.deny.length > 0) {
return true;
}
if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) {
return true;
}
if (plugins.entries && Object.keys(plugins.entries).length > 0) {
return true;
}
if (plugins.slots && Object.keys(plugins.slots).length > 0) {
return true;
}
return false;
}
const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = BUNDLED_WEB_SEARCH_PLUGINS.map(
(plugin) => plugin.id,
);
function resolveBundledWebSearchCompatPluginIds(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
void params;
return pluginRegistrationContractRegistry
.filter((plugin) => plugin.webSearchProviderIds.length > 0)
.map((plugin) => plugin.pluginId)
.toSorted((left, right) => left.localeCompare(right));
}
function withBundledWebSearchVitestCompat(params: {
config: PluginLoadOptions["config"];
pluginIds: readonly string[];
env?: PluginLoadOptions["env"];
}): PluginLoadOptions["config"] {
const env = params.env ?? process.env;
const isVitest = Boolean(env.VITEST || process.env.VITEST);
if (!isVitest || hasExplicitPluginConfig(params.config) || params.pluginIds.length === 0) {
return params.config;
}
return {
...params.config,
plugins: {
...params.config?.plugins,
enabled: true,
allow: [...params.pluginIds],
slots: {
...params.config?.plugins?.slots,
memory: "none",
},
},
};
}
function applyVitestContractMetadataCompat(
providers: PluginWebSearchProviderEntry[],
env?: PluginLoadOptions["env"],
): PluginWebSearchProviderEntry[] {
if (!(env?.VITEST || process.env.VITEST)) {
return providers;
}
return providers.map((provider) => {
const contract = webSearchProviderContractRegistry.find(
(entry) => entry.pluginId === provider.pluginId && entry.provider.id === provider.id,
);
if (!contract) {
return provider;
}
return {
...contract.provider,
...provider,
credentialPath: provider.credentialPath ?? contract.provider.credentialPath,
inactiveSecretPaths: provider.inactiveSecretPaths ?? contract.provider.inactiveSecretPaths,
applySelectionConfig: provider.applySelectionConfig ?? contract.provider.applySelectionConfig,
resolveRuntimeMetadata:
provider.resolveRuntimeMetadata ?? contract.provider.resolveRuntimeMetadata,
};
});
}
function sortWebSearchProviders(
providers: PluginWebSearchProviderEntry[],
@@ -46,74 +117,52 @@ function sortWebSearchProviders(
});
}
function mapWebSearchProviderEntries(
entries: PluginWebSearchProviderRegistration[],
): PluginWebSearchProviderEntry[] {
return sortWebSearchProviders(
entries.map((entry) => ({
...entry.provider,
pluginId: entry.pluginId,
})),
);
}
function normalizeWebSearchPluginConfig(params: {
config?: PluginLoadOptions["config"];
bundledAllowlistCompat?: boolean;
}): PluginLoadOptions["config"] {
const allowlistCompat = params.bundledAllowlistCompat
? withBundledPluginAllowlistCompat({
config: params.config,
pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS,
})
: params.config;
return withBundledPluginEnablementCompat({
config: allowlistCompat,
pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS,
});
}
function captureBundledWebSearchProviders(
plugin: RegistrablePlugin,
): PluginWebSearchProviderRegistration[] {
const captured = capturePluginRegistration(plugin);
return captured.webSearchProviders.map((provider) => ({
pluginId: plugin.id,
pluginName: plugin.name,
provider,
source: "bundled",
}));
}
function resolveBundledWebSearchRegistrations(params: {
config?: PluginLoadOptions["config"];
bundledAllowlistCompat?: boolean;
}): PluginWebSearchProviderRegistration[] {
const config = normalizeWebSearchPluginConfig(params);
if (config?.plugins?.enabled === false) {
return [];
}
const allowlist = config?.plugins?.allow
? new Set(config.plugins.allow.map((entry) => entry.trim()).filter(Boolean))
: null;
return BUNDLED_WEB_SEARCH_PLUGINS.flatMap((plugin) => {
if (allowlist && !allowlist.has(plugin.id)) {
return [];
}
if (config?.plugins?.entries?.[plugin.id]?.enabled === false) {
return [];
}
return captureBundledWebSearchProviders(plugin);
});
}
export function resolvePluginWebSearchProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
bundledAllowlistCompat?: boolean;
activate?: boolean;
cache?: boolean;
}): PluginWebSearchProviderEntry[] {
return mapWebSearchProviderEntries(resolveBundledWebSearchRegistrations(params));
const bundledCompatPluginIds = resolveBundledWebSearchCompatPluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const allowlistCompat = params.bundledAllowlistCompat
? withBundledPluginAllowlistCompat({
config: params.config,
pluginIds: bundledCompatPluginIds,
})
: params.config;
const enablementCompat = withBundledPluginEnablementCompat({
config: allowlistCompat,
pluginIds: bundledCompatPluginIds,
});
const config = withBundledWebSearchVitestCompat({
config: enablementCompat,
pluginIds: bundledCompatPluginIds,
env: params.env,
});
const registry = loadOpenClawPlugins({
config,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache ?? false,
activate: params.activate ?? false,
logger: createPluginLoaderLogger(log),
});
return sortWebSearchProviders(
applyVitestContractMetadataCompat(
registry.webSearchProviders.map((entry) => ({
...entry.provider,
pluginId: entry.pluginId,
})),
params.env,
),
);
}
export function resolveRuntimeWebSearchProviders(params: {
@@ -124,7 +173,12 @@ export function resolveRuntimeWebSearchProviders(params: {
}): PluginWebSearchProviderEntry[] {
const runtimeProviders = getActivePluginRegistry()?.webSearchProviders ?? [];
if (runtimeProviders.length > 0) {
return mapWebSearchProviderEntries(runtimeProviders);
return sortWebSearchProviders(
runtimeProviders.map((entry) => ({
...entry.provider,
pluginId: entry.pluginId,
})),
);
}
return resolvePluginWebSearchProviders(params);
}

View File

@@ -1,5 +1,9 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import type {
PluginWebSearchProviderEntry,
WebSearchCredentialResolutionSource,
} from "../plugins/types.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import { secretRefKey } from "./ref-contract.js";
@@ -18,14 +22,8 @@ import type {
RuntimeWebToolsMetadata,
} from "./runtime-web-tools.types.js";
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
type WebSearchProvider = string;
type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; // pragma: allowlist secret
export type {
RuntimeWebDiagnostic,
RuntimeWebDiagnosticCode,
@@ -42,7 +40,7 @@ type FetchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
type SecretResolutionResult = {
value?: string;
source: SecretResolutionSource;
source: WebSearchCredentialResolutionSource;
secretRefConfigured: boolean;
unresolvedRefReason?: string;
fallbackEnvVar?: string;
@@ -198,60 +196,6 @@ async function resolveSecretInputWithEnvFallback(params: {
};
}
function inferPerplexityBaseUrlFromApiKey(apiKey?: string): "direct" | "openrouter" | undefined {
if (!apiKey) {
return undefined;
}
const normalized = apiKey.toLowerCase();
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "direct";
}
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "openrouter";
}
return undefined;
}
function resolvePerplexityRuntimeTransport(params: {
keyValue?: string;
keySource: SecretResolutionSource;
fallbackEnvVar?: string;
configValue: unknown;
}): "search_api" | "chat_completions" | undefined {
const config = isRecord(params.configValue) ? params.configValue : undefined;
const configuredBaseUrl = typeof config?.baseUrl === "string" ? config.baseUrl.trim() : "";
const configuredModel = typeof config?.model === "string" ? config.model.trim() : "";
const baseUrl = (() => {
if (configuredBaseUrl) {
return configuredBaseUrl;
}
if (params.keySource === "env") {
if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") {
return PERPLEXITY_DIRECT_BASE_URL;
}
if (params.fallbackEnvVar === "OPENROUTER_API_KEY") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
}
if ((params.keySource === "config" || params.keySource === "secretRef") && params.keyValue) {
const inferred = inferPerplexityBaseUrlFromApiKey(params.keyValue);
return inferred === "openrouter" ? DEFAULT_PERPLEXITY_BASE_URL : PERPLEXITY_DIRECT_BASE_URL;
}
return DEFAULT_PERPLEXITY_BASE_URL;
})();
const hasLegacyOverride = Boolean(configuredBaseUrl || configuredModel);
const direct = (() => {
try {
return new URL(baseUrl).hostname.toLowerCase() === "api.perplexity.ai";
} catch {
return false;
}
})();
return hasLegacyOverride || !direct ? "chat_completions" : "search_api";
}
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
const current = target[key];
if (isRecord(current)) {
@@ -291,8 +235,14 @@ function setResolvedFirecrawlApiKey(params: {
firecrawl.apiKey = params.value;
}
function keyPathForProvider(provider: WebSearchProvider): string {
return provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
function keyPathForProvider(provider: PluginWebSearchProviderEntry): string {
return provider.credentialPath;
}
function inactivePathsForProvider(provider: PluginWebSearchProviderEntry): string[] {
return provider.inactiveSecretPaths?.length
? provider.inactiveSecretPaths
: [provider.credentialPath];
}
function hasConfiguredSecretRef(value: unknown, defaults: SecretDefaults | undefined): boolean {
@@ -367,7 +317,7 @@ export async function resolveRuntimeWebTools(params: {
let selectedResolution: SecretResolutionResult | undefined;
for (const provider of candidates) {
const path = keyPathForProvider(provider.id);
const path = keyPathForProvider(provider);
const value = provider.getCredentialValue(search);
const resolution = await resolveSecretInputWithEnvFallback({
sourceConfig: params.sourceConfig,
@@ -475,13 +425,23 @@ export async function resolveRuntimeWebTools(params: {
if (!configuredProvider) {
searchMetadata.providerSource = "auto-detect";
}
if (selectedProvider === "perplexity") {
searchMetadata.perplexityTransport = resolvePerplexityRuntimeTransport({
keyValue: selectedResolution?.value,
keySource: selectedResolution?.source ?? "missing",
fallbackEnvVar: selectedResolution?.fallbackEnvVar,
configValue: search.perplexity,
});
const provider = providers.find((entry) => entry.id === selectedProvider);
if (provider?.resolveRuntimeMetadata) {
Object.assign(
searchMetadata,
await provider.resolveRuntimeMetadata({
config: params.sourceConfig,
searchConfig: search,
runtimeMetadata: searchMetadata,
resolvedCredential: selectedResolution
? {
value: selectedResolution.value,
source: selectedResolution.source,
fallbackEnvVar: selectedResolution.fallbackEnvVar,
}
: undefined,
}),
);
}
}
}
@@ -491,29 +451,31 @@ export async function resolveRuntimeWebTools(params: {
if (provider.id === searchMetadata.selectedProvider) {
continue;
}
const path = keyPathForProvider(provider.id);
const value = provider.getCredentialValue(search);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
pushInactiveSurfaceWarning({
context: params.context,
path,
details: `tools.web.search auto-detected provider is "${searchMetadata.selectedProvider}".`,
});
for (const path of inactivePathsForProvider(provider)) {
pushInactiveSurfaceWarning({
context: params.context,
path,
details: `tools.web.search auto-detected provider is "${searchMetadata.selectedProvider}".`,
});
}
}
} else if (search && !searchEnabled) {
for (const provider of providers) {
const path = keyPathForProvider(provider.id);
const value = provider.getCredentialValue(search);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
pushInactiveSurfaceWarning({
context: params.context,
path,
details: "tools.web.search is disabled.",
});
for (const path of inactivePathsForProvider(provider)) {
pushInactiveSurfaceWarning({
context: params.context,
path,
details: "tools.web.search is disabled.",
});
}
}
}
@@ -522,16 +484,17 @@ export async function resolveRuntimeWebTools(params: {
if (provider.id === configuredProvider) {
continue;
}
const path = keyPathForProvider(provider.id);
const value = provider.getCredentialValue(search);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
pushInactiveSurfaceWarning({
context: params.context,
path,
details: `tools.web.search.provider is "${configuredProvider}".`,
});
for (const path of inactivePathsForProvider(provider)) {
pushInactiveSurfaceWarning({
context: params.context,
path,
details: `tools.web.search.provider is "${configuredProvider}".`,
});
}
}
}