refactor web search provider execution out of core
This commit is contained in:
parent
df72ca1ece
commit
3de973ffff
@ -1,28 +1,11 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getTopLevelCredentialValue,
|
||||
setTopLevelCredentialValue,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "brave",
|
||||
name: "Brave Plugin",
|
||||
description: "Bundled Brave plugin",
|
||||
register(api) {
|
||||
api.registerWebSearchProvider(
|
||||
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,
|
||||
}),
|
||||
);
|
||||
api.registerWebSearchProvider(createBraveWebSearchProvider());
|
||||
},
|
||||
});
|
||||
|
||||
613
extensions/brave/src/brave-web-search-provider.ts
Normal file
613
extensions/brave/src/brave-web-search-provider.ts
Normal file
@ -0,0 +1,613 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js";
|
||||
import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js";
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
MAX_SEARCH_COUNT,
|
||||
normalizeFreshness,
|
||||
normalizeToIsoDate,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readProviderEnvValue,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
withTrustedWebSearchEndpoint,
|
||||
writeCachedSearchPayload,
|
||||
} from "../../../src/agents/tools/web-search-provider-common.js";
|
||||
import { formatCliCommand } from "../../../src/cli/command-format.js";
|
||||
import type {
|
||||
WebSearchProviderPlugin,
|
||||
WebSearchProviderToolDefinition,
|
||||
} from "../../../src/plugins/types.js";
|
||||
import { wrapWebContent } from "../../../src/security/external-content.js";
|
||||
|
||||
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
|
||||
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
|
||||
const BRAVE_SEARCH_LANG_CODES = new Set([
|
||||
"ar",
|
||||
"eu",
|
||||
"bn",
|
||||
"bg",
|
||||
"ca",
|
||||
"zh-hans",
|
||||
"zh-hant",
|
||||
"hr",
|
||||
"cs",
|
||||
"da",
|
||||
"nl",
|
||||
"en",
|
||||
"en-gb",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"gl",
|
||||
"de",
|
||||
"el",
|
||||
"gu",
|
||||
"he",
|
||||
"hi",
|
||||
"hu",
|
||||
"is",
|
||||
"it",
|
||||
"jp",
|
||||
"kn",
|
||||
"ko",
|
||||
"lv",
|
||||
"lt",
|
||||
"ms",
|
||||
"ml",
|
||||
"mr",
|
||||
"nb",
|
||||
"pl",
|
||||
"pt-br",
|
||||
"pt-pt",
|
||||
"pa",
|
||||
"ro",
|
||||
"ru",
|
||||
"sr",
|
||||
"sk",
|
||||
"sl",
|
||||
"es",
|
||||
"sv",
|
||||
"ta",
|
||||
"te",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
]);
|
||||
const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
|
||||
ja: "jp",
|
||||
zh: "zh-hans",
|
||||
"zh-cn": "zh-hans",
|
||||
"zh-hk": "zh-hant",
|
||||
"zh-sg": "zh-hans",
|
||||
"zh-tw": "zh-hant",
|
||||
};
|
||||
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
|
||||
|
||||
type BraveConfig = {
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
type BraveSearchResult = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
age?: string;
|
||||
};
|
||||
|
||||
type BraveSearchResponse = {
|
||||
web?: {
|
||||
results?: BraveSearchResult[];
|
||||
};
|
||||
};
|
||||
|
||||
type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
|
||||
type BraveLlmContextResponse = {
|
||||
grounding: { generic?: BraveLlmContextResult[] };
|
||||
sources?: { url?: string; hostname?: string; date?: string }[];
|
||||
};
|
||||
|
||||
function resolveBraveConfig(searchConfig?: SearchConfigRecord): BraveConfig {
|
||||
const brave = searchConfig?.brave;
|
||||
return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {};
|
||||
}
|
||||
|
||||
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
|
||||
return brave.mode === "llm-context" ? "llm-context" : "web";
|
||||
}
|
||||
|
||||
function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
|
||||
readProviderEnvValue(["BRAVE_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase();
|
||||
if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) {
|
||||
return undefined;
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function normalizeBraveUiLang(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const match = trimmed.match(BRAVE_UI_LANG_LOCALE);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const [, language, region] = match;
|
||||
return `${language.toLowerCase()}-${region.toUpperCase()}`;
|
||||
}
|
||||
|
||||
function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
|
||||
search_lang?: string;
|
||||
ui_lang?: string;
|
||||
invalidField?: "search_lang" | "ui_lang";
|
||||
} {
|
||||
const rawSearchLang = params.search_lang?.trim() || undefined;
|
||||
const rawUiLang = params.ui_lang?.trim() || undefined;
|
||||
let searchLangCandidate = rawSearchLang;
|
||||
let uiLangCandidate = rawUiLang;
|
||||
|
||||
if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) {
|
||||
searchLangCandidate = rawUiLang;
|
||||
uiLangCandidate = rawSearchLang;
|
||||
}
|
||||
|
||||
const search_lang = normalizeBraveSearchLang(searchLangCandidate);
|
||||
if (searchLangCandidate && !search_lang) {
|
||||
return { invalidField: "search_lang" };
|
||||
}
|
||||
|
||||
const ui_lang = normalizeBraveUiLang(uiLangCandidate);
|
||||
if (uiLangCandidate && !ui_lang) {
|
||||
return { invalidField: "ui_lang" };
|
||||
}
|
||||
|
||||
return { search_lang, ui_lang };
|
||||
}
|
||||
|
||||
function mapBraveLlmContextResults(
|
||||
data: BraveLlmContextResponse,
|
||||
): { url: string; title: string; snippets: string[]; siteName?: string }[] {
|
||||
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
|
||||
return genericResults.map((entry) => ({
|
||||
url: entry.url ?? "",
|
||||
title: entry.title ?? "",
|
||||
snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0),
|
||||
siteName: resolveSiteName(entry.url) || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async function runBraveLlmContextSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
freshness?: string;
|
||||
}): Promise<{
|
||||
results: Array<{
|
||||
url: string;
|
||||
title: string;
|
||||
snippets: string[];
|
||||
siteName?: string;
|
||||
}>;
|
||||
sources?: BraveLlmContextResponse["sources"];
|
||||
}> {
|
||||
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
|
||||
url.searchParams.set("q", params.query);
|
||||
if (params.country) {
|
||||
url.searchParams.set("country", params.country);
|
||||
}
|
||||
if (params.search_lang) {
|
||||
url.searchParams.set("search_lang", params.search_lang);
|
||||
}
|
||||
if (params.freshness) {
|
||||
url.searchParams.set("freshness", params.freshness);
|
||||
}
|
||||
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: url.toString(),
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Subscription-Token": params.apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BraveLlmContextResponse;
|
||||
return { results: mapBraveLlmContextResults(data), sources: data.sources };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function runBraveWebSearch(params: {
|
||||
query: string;
|
||||
count: number;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
ui_lang?: string;
|
||||
freshness?: string;
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const url = new URL(BRAVE_SEARCH_ENDPOINT);
|
||||
url.searchParams.set("q", params.query);
|
||||
url.searchParams.set("count", String(params.count));
|
||||
if (params.country) {
|
||||
url.searchParams.set("country", params.country);
|
||||
}
|
||||
if (params.search_lang) {
|
||||
url.searchParams.set("search_lang", params.search_lang);
|
||||
}
|
||||
if (params.ui_lang) {
|
||||
url.searchParams.set("ui_lang", params.ui_lang);
|
||||
}
|
||||
if (params.freshness) {
|
||||
url.searchParams.set("freshness", params.freshness);
|
||||
} else if (params.dateAfter && params.dateBefore) {
|
||||
url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
|
||||
} else if (params.dateAfter) {
|
||||
url.searchParams.set(
|
||||
"freshness",
|
||||
`${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
|
||||
);
|
||||
} else if (params.dateBefore) {
|
||||
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
|
||||
}
|
||||
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: url.toString(),
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Subscription-Token": params.apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BraveSearchResponse;
|
||||
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
|
||||
return results.map((entry) => {
|
||||
const description = entry.description ?? "";
|
||||
const title = entry.title ?? "";
|
||||
const url = entry.url ?? "";
|
||||
return {
|
||||
title: title ? wrapWebContent(title, "web_search") : "",
|
||||
url,
|
||||
description: description ? wrapWebContent(description, "web_search") : "",
|
||||
published: entry.age || undefined,
|
||||
siteName: resolveSiteName(url) || undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createBraveSchema() {
|
||||
return Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
country: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
|
||||
}),
|
||||
),
|
||||
language: Type.Optional(
|
||||
Type.String({
|
||||
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
|
||||
}),
|
||||
),
|
||||
freshness: Type.Optional(
|
||||
Type.String({
|
||||
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
|
||||
}),
|
||||
),
|
||||
date_after: Type.Optional(
|
||||
Type.String({
|
||||
description: "Only results published after this date (YYYY-MM-DD).",
|
||||
}),
|
||||
),
|
||||
date_before: Type.Optional(
|
||||
Type.String({
|
||||
description: "Only results published before this date (YYYY-MM-DD).",
|
||||
}),
|
||||
),
|
||||
search_lang: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
|
||||
}),
|
||||
),
|
||||
ui_lang: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function missingBraveKeyPayload() {
|
||||
return {
|
||||
error: "missing_brave_api_key",
|
||||
message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
function createBraveToolDefinition(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
): WebSearchProviderToolDefinition {
|
||||
const braveConfig = resolveBraveConfig(searchConfig);
|
||||
const braveMode = resolveBraveMode(braveConfig);
|
||||
|
||||
return {
|
||||
description:
|
||||
braveMode === "llm-context"
|
||||
? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding."
|
||||
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
|
||||
parameters: createBraveSchema(),
|
||||
execute: async (args) => {
|
||||
const apiKey = resolveBraveApiKey(searchConfig);
|
||||
if (!apiKey) {
|
||||
return missingBraveKeyPayload();
|
||||
}
|
||||
|
||||
const params = args as Record<string, unknown>;
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
const country = readStringParam(params, "country");
|
||||
const language = readStringParam(params, "language");
|
||||
const search_lang = readStringParam(params, "search_lang");
|
||||
const ui_lang = readStringParam(params, "ui_lang");
|
||||
const normalizedLanguage = normalizeBraveLanguageParams({
|
||||
search_lang: search_lang || language,
|
||||
ui_lang,
|
||||
});
|
||||
if (normalizedLanguage.invalidField === "search_lang") {
|
||||
return {
|
||||
error: "invalid_search_lang",
|
||||
message:
|
||||
"search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (normalizedLanguage.invalidField === "ui_lang") {
|
||||
return {
|
||||
error: "invalid_ui_lang",
|
||||
message: "ui_lang must be a language-region locale like 'en-US'.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (normalizedLanguage.ui_lang && braveMode === "llm-context") {
|
||||
return {
|
||||
error: "unsupported_ui_lang",
|
||||
message:
|
||||
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const rawFreshness = readStringParam(params, "freshness");
|
||||
if (rawFreshness && braveMode === "llm-context") {
|
||||
return {
|
||||
error: "unsupported_freshness",
|
||||
message:
|
||||
"freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined;
|
||||
if (rawFreshness && !freshness) {
|
||||
return {
|
||||
error: "invalid_freshness",
|
||||
message: "freshness must be day, week, month, or year.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const rawDateAfter = readStringParam(params, "date_after");
|
||||
const rawDateBefore = readStringParam(params, "date_before");
|
||||
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
|
||||
return {
|
||||
error: "conflicting_time_filters",
|
||||
message:
|
||||
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if ((rawDateAfter || rawDateBefore) && braveMode === "llm-context") {
|
||||
return {
|
||||
error: "unsupported_date_filter",
|
||||
message:
|
||||
"date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
|
||||
if (rawDateAfter && !dateAfter) {
|
||||
return {
|
||||
error: "invalid_date",
|
||||
message: "date_after must be YYYY-MM-DD format.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined;
|
||||
if (rawDateBefore && !dateBefore) {
|
||||
return {
|
||||
error: "invalid_date",
|
||||
message: "date_before must be YYYY-MM-DD format.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (dateAfter && dateBefore && dateAfter > dateBefore) {
|
||||
return {
|
||||
error: "invalid_date_range",
|
||||
message: "date_after must be before date_before.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"brave",
|
||||
braveMode,
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
country,
|
||||
normalizedLanguage.search_lang,
|
||||
normalizedLanguage.ui_lang,
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
|
||||
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
|
||||
|
||||
if (braveMode === "llm-context") {
|
||||
const { results, sources } = await runBraveLlmContextSearch({
|
||||
query,
|
||||
apiKey,
|
||||
timeoutSeconds,
|
||||
country: country ?? undefined,
|
||||
search_lang: normalizedLanguage.search_lang,
|
||||
freshness,
|
||||
});
|
||||
const payload = {
|
||||
query,
|
||||
provider: "brave",
|
||||
mode: "llm-context" as const,
|
||||
count: results.length,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "brave",
|
||||
wrapped: true,
|
||||
},
|
||||
results: results.map((entry) => ({
|
||||
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
|
||||
url: entry.url,
|
||||
snippets: entry.snippets.map((snippet) => wrapWebContent(snippet, "web_search")),
|
||||
siteName: entry.siteName,
|
||||
})),
|
||||
sources,
|
||||
};
|
||||
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
|
||||
const results = await runBraveWebSearch({
|
||||
query,
|
||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
apiKey,
|
||||
timeoutSeconds,
|
||||
country: country ?? undefined,
|
||||
search_lang: normalizedLanguage.search_lang,
|
||||
ui_lang: normalizedLanguage.ui_lang,
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
});
|
||||
const payload = {
|
||||
query,
|
||||
provider: "brave",
|
||||
count: results.length,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "brave",
|
||||
wrapped: true,
|
||||
},
|
||||
results,
|
||||
};
|
||||
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
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,
|
||||
credentialPath: "tools.web.search.apiKey",
|
||||
inactiveSecretPaths: ["tools.web.search.apiKey"],
|
||||
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
|
||||
setCredentialValue: (searchConfigTarget, value) => {
|
||||
searchConfigTarget.apiKey = value;
|
||||
},
|
||||
createTool: (ctx) =>
|
||||
createBraveToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
normalizeFreshness,
|
||||
normalizeBraveLanguageParams,
|
||||
resolveBraveMode,
|
||||
mapBraveLlmContextResults,
|
||||
} as const;
|
||||
@ -1,5 +1,6 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { enablePluginInConfig } from "../../../src/plugins/enable.js";
|
||||
import type { WebSearchProviderPlugin } from "../../../src/plugins/types.js";
|
||||
import { runFirecrawlSearch } from "./firecrawl-client.js";
|
||||
|
||||
const GenericFirecrawlSearchSchema = Type.Object(
|
||||
@ -46,8 +47,11 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
|
||||
signupUrl: "https://www.firecrawl.dev/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/firecrawl",
|
||||
autoDetectOrder: 60,
|
||||
credentialPath: "tools.web.search.firecrawl.apiKey",
|
||||
inactiveSecretPaths: ["tools.web.search.firecrawl.apiKey"],
|
||||
getCredentialValue: getScopedCredentialValue,
|
||||
setCredentialValue: setScopedCredentialValue,
|
||||
applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config,
|
||||
createTool: (ctx) => ({
|
||||
description:
|
||||
"Search the web using Firecrawl. Returns structured results with snippets from Firecrawl Search. Use firecrawl_search for Firecrawl-specific knobs like sources or categories.",
|
||||
|
||||
@ -5,14 +5,10 @@ import {
|
||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
applyGoogleGeminiModelDefault,
|
||||
} from "openclaw/plugin-sdk/provider-models";
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getScopedCredentialValue,
|
||||
setScopedCredentialValue,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
|
||||
import { googleMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
|
||||
import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "google",
|
||||
@ -53,20 +49,6 @@ export default definePluginEntry({
|
||||
registerGoogleGeminiCliProvider(api);
|
||||
api.registerImageGenerationProvider(buildGoogleImageGenerationProvider());
|
||||
api.registerMediaUnderstandingProvider(googleMediaUnderstandingProvider);
|
||||
api.registerWebSearchProvider(
|
||||
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),
|
||||
}),
|
||||
);
|
||||
api.registerWebSearchProvider(createGeminiWebSearchProvider());
|
||||
},
|
||||
});
|
||||
|
||||
286
extensions/google/src/gemini-web-search-provider.ts
Normal file
286
extensions/google/src/gemini-web-search-provider.ts
Normal file
@ -0,0 +1,286 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js";
|
||||
import { resolveCitationRedirectUrl } from "../../../src/agents/tools/web-search-citation-redirect.js";
|
||||
import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js";
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
MAX_SEARCH_COUNT,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readProviderEnvValue,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
withTrustedWebSearchEndpoint,
|
||||
writeCachedSearchPayload,
|
||||
} from "../../../src/agents/tools/web-search-provider-common.js";
|
||||
import type {
|
||||
WebSearchProviderPlugin,
|
||||
WebSearchProviderToolDefinition,
|
||||
} from "../../../src/plugins/types.js";
|
||||
import { wrapWebContent } from "../../../src/security/external-content.js";
|
||||
|
||||
const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
|
||||
const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
||||
|
||||
type GeminiConfig = {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type GeminiGroundingResponse = {
|
||||
candidates?: Array<{
|
||||
content?: {
|
||||
parts?: Array<{
|
||||
text?: string;
|
||||
}>;
|
||||
};
|
||||
groundingMetadata?: {
|
||||
groundingChunks?: Array<{
|
||||
web?: {
|
||||
uri?: string;
|
||||
title?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
error?: {
|
||||
code?: number;
|
||||
message?: string;
|
||||
status?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function resolveGeminiConfig(searchConfig?: SearchConfigRecord): GeminiConfig {
|
||||
const gemini = searchConfig?.gemini;
|
||||
return gemini && typeof gemini === "object" && !Array.isArray(gemini)
|
||||
? (gemini as GeminiConfig)
|
||||
: {};
|
||||
}
|
||||
|
||||
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ??
|
||||
readProviderEnvValue(["GEMINI_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGeminiModel(gemini?: GeminiConfig): string {
|
||||
const model = typeof gemini?.model === "string" ? gemini.model.trim() : "";
|
||||
return model || DEFAULT_GEMINI_MODEL;
|
||||
}
|
||||
|
||||
async function runGeminiSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> {
|
||||
const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`;
|
||||
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": params.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: params.query }] }],
|
||||
tools: [{ google_search: {} }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const safeDetail = ((await res.text()) || res.statusText).replace(
|
||||
/key=[^&\s]+/gi,
|
||||
"key=***",
|
||||
);
|
||||
throw new Error(`Gemini API error (${res.status}): ${safeDetail}`);
|
||||
}
|
||||
|
||||
let data: GeminiGroundingResponse;
|
||||
try {
|
||||
data = (await res.json()) as GeminiGroundingResponse;
|
||||
} catch (error) {
|
||||
const safeError = String(error).replace(/key=[^&\s]+/gi, "key=***");
|
||||
throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: error });
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
const rawMessage = data.error.message || data.error.status || "unknown";
|
||||
throw new Error(
|
||||
`Gemini API error (${data.error.code}): ${rawMessage.replace(/key=[^&\s]+/gi, "key=***")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const candidate = data.candidates?.[0];
|
||||
const content =
|
||||
candidate?.content?.parts
|
||||
?.map((part) => part.text)
|
||||
.filter(Boolean)
|
||||
.join("\n") ?? "No response";
|
||||
const rawCitations = (candidate?.groundingMetadata?.groundingChunks ?? [])
|
||||
.filter((chunk) => chunk.web?.uri)
|
||||
.map((chunk) => ({
|
||||
url: chunk.web!.uri!,
|
||||
title: chunk.web?.title || undefined,
|
||||
}));
|
||||
|
||||
const citations: Array<{ url: string; title?: string }> = [];
|
||||
for (let index = 0; index < rawCitations.length; index += 10) {
|
||||
const batch = rawCitations.slice(index, index + 10);
|
||||
const resolved = await Promise.all(
|
||||
batch.map(async (citation) => ({
|
||||
...citation,
|
||||
url: await resolveCitationRedirectUrl(citation.url),
|
||||
})),
|
||||
);
|
||||
citations.push(...resolved);
|
||||
}
|
||||
|
||||
return { content, citations };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGeminiSchema() {
|
||||
return Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
country: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
|
||||
language: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
|
||||
freshness: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
|
||||
date_after: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
|
||||
date_before: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
|
||||
});
|
||||
}
|
||||
|
||||
function createGeminiToolDefinition(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
): WebSearchProviderToolDefinition {
|
||||
return {
|
||||
description:
|
||||
"Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.",
|
||||
parameters: createGeminiSchema(),
|
||||
execute: async (args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
for (const name of ["country", "language", "freshness", "date_after", "date_before"]) {
|
||||
if (readStringParam(params, name)) {
|
||||
const label =
|
||||
name === "country"
|
||||
? "country filtering"
|
||||
: name === "language"
|
||||
? "language filtering"
|
||||
: name === "freshness"
|
||||
? "freshness filtering"
|
||||
: "date_after/date_before filtering";
|
||||
return {
|
||||
error: name.startsWith("date_") ? "unsupported_date_filter" : `unsupported_${name}`,
|
||||
message: `${label} is not supported by the gemini provider. Only Brave and Perplexity support ${name === "country" ? "country filtering" : name === "language" ? "language filtering" : name === "freshness" ? "freshness" : "date filtering"}.`,
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const geminiConfig = resolveGeminiConfig(searchConfig);
|
||||
const apiKey = resolveGeminiApiKey(geminiConfig);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error: "missing_gemini_api_key",
|
||||
message:
|
||||
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
const model = resolveGeminiModel(geminiConfig);
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"gemini",
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
model,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const result = await runGeminiSearch({
|
||||
query,
|
||||
apiKey,
|
||||
model,
|
||||
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
||||
});
|
||||
const payload = {
|
||||
query,
|
||||
provider: "gemini",
|
||||
model,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "gemini",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(result.content),
|
||||
citations: result.citations,
|
||||
};
|
||||
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
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,
|
||||
credentialPath: "tools.web.search.gemini.apiKey",
|
||||
inactiveSecretPaths: ["tools.web.search.gemini.apiKey"],
|
||||
getCredentialValue: (searchConfig) => {
|
||||
const gemini = searchConfig?.gemini;
|
||||
return gemini && typeof gemini === "object" && !Array.isArray(gemini)
|
||||
? (gemini as Record<string, unknown>).apiKey
|
||||
: undefined;
|
||||
},
|
||||
setCredentialValue: (searchConfigTarget, value) => {
|
||||
const scoped = searchConfigTarget.gemini;
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
searchConfigTarget.gemini = { apiKey: value };
|
||||
return;
|
||||
}
|
||||
(scoped as Record<string, unknown>).apiKey = value;
|
||||
},
|
||||
createTool: (ctx) =>
|
||||
createGeminiToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveGeminiApiKey,
|
||||
resolveGeminiModel,
|
||||
} as const;
|
||||
@ -1,15 +1,10 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
createMoonshotThinkingWrapper,
|
||||
resolveMoonshotThinkingType,
|
||||
} from "openclaw/plugin-sdk/provider-stream";
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getScopedCredentialValue,
|
||||
setScopedCredentialValue,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import {
|
||||
applyMoonshotConfig,
|
||||
@ -17,6 +12,7 @@ import {
|
||||
MOONSHOT_DEFAULT_MODEL_REF,
|
||||
} from "./onboard.js";
|
||||
import { buildMoonshotProvider } from "./provider-catalog.js";
|
||||
import { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js";
|
||||
|
||||
const PROVIDER_ID = "moonshot";
|
||||
|
||||
@ -91,20 +87,6 @@ export default definePluginEntry({
|
||||
},
|
||||
});
|
||||
api.registerMediaUnderstandingProvider(moonshotMediaUnderstandingProvider);
|
||||
api.registerWebSearchProvider(
|
||||
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),
|
||||
}),
|
||||
);
|
||||
api.registerWebSearchProvider(createKimiWebSearchProvider());
|
||||
},
|
||||
});
|
||||
|
||||
360
extensions/moonshot/src/kimi-web-search-provider.ts
Normal file
360
extensions/moonshot/src/kimi-web-search-provider.ts
Normal file
@ -0,0 +1,360 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js";
|
||||
import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js";
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
MAX_SEARCH_COUNT,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readProviderEnvValue,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
withTrustedWebSearchEndpoint,
|
||||
writeCachedSearchPayload,
|
||||
} from "../../../src/agents/tools/web-search-provider-common.js";
|
||||
import type {
|
||||
WebSearchProviderPlugin,
|
||||
WebSearchProviderToolDefinition,
|
||||
} from "../../../src/plugins/types.js";
|
||||
import { wrapWebContent } from "../../../src/security/external-content.js";
|
||||
|
||||
const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1";
|
||||
const DEFAULT_KIMI_MODEL = "moonshot-v1-128k";
|
||||
const KIMI_WEB_SEARCH_TOOL = {
|
||||
type: "builtin_function",
|
||||
function: { name: "$web_search" },
|
||||
} as const;
|
||||
|
||||
type KimiConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type KimiToolCall = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type KimiMessage = {
|
||||
role?: string;
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
tool_calls?: KimiToolCall[];
|
||||
};
|
||||
|
||||
type KimiSearchResponse = {
|
||||
choices?: Array<{
|
||||
finish_reason?: string;
|
||||
message?: KimiMessage;
|
||||
}>;
|
||||
search_results?: Array<{
|
||||
title?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig {
|
||||
const kimi = searchConfig?.kimi;
|
||||
return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {};
|
||||
}
|
||||
|
||||
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ??
|
||||
readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function resolveKimiModel(kimi?: KimiConfig): string {
|
||||
const model = typeof kimi?.model === "string" ? kimi.model.trim() : "";
|
||||
return model || DEFAULT_KIMI_MODEL;
|
||||
}
|
||||
|
||||
function resolveKimiBaseUrl(kimi?: KimiConfig): string {
|
||||
const baseUrl = typeof kimi?.baseUrl === "string" ? kimi.baseUrl.trim() : "";
|
||||
return baseUrl || DEFAULT_KIMI_BASE_URL;
|
||||
}
|
||||
|
||||
function extractKimiMessageText(message: KimiMessage | undefined): string | undefined {
|
||||
const content = message?.content?.trim();
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
const reasoning = message?.reasoning_content?.trim();
|
||||
return reasoning || undefined;
|
||||
}
|
||||
|
||||
function extractKimiCitations(data: KimiSearchResponse): string[] {
|
||||
const citations = (data.search_results ?? [])
|
||||
.map((entry) => entry.url?.trim())
|
||||
.filter((url): url is string => Boolean(url));
|
||||
|
||||
for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) {
|
||||
const rawArguments = toolCall.function?.arguments;
|
||||
if (!rawArguments) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawArguments) as {
|
||||
search_results?: Array<{ url?: string }>;
|
||||
url?: string;
|
||||
};
|
||||
if (typeof parsed.url === "string" && parsed.url.trim()) {
|
||||
citations.push(parsed.url.trim());
|
||||
}
|
||||
for (const result of parsed.search_results ?? []) {
|
||||
if (typeof result.url === "string" && result.url.trim()) {
|
||||
citations.push(result.url.trim());
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed tool arguments
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(citations)];
|
||||
}
|
||||
|
||||
function buildKimiToolResultContent(data: KimiSearchResponse): string {
|
||||
return JSON.stringify({
|
||||
search_results: (data.search_results ?? []).map((entry) => ({
|
||||
title: entry.title ?? "",
|
||||
url: entry.url ?? "",
|
||||
content: entry.content ?? "",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async function runKimiSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<{ content: string; citations: string[] }> {
|
||||
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
|
||||
const messages: Array<Record<string, unknown>> = [{ role: "user", content: params.query }];
|
||||
const collectedCitations = new Set<string>();
|
||||
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
const next = await withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
messages,
|
||||
tools: [KIMI_WEB_SEARCH_TOOL],
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (
|
||||
res,
|
||||
): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`Kimi API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as KimiSearchResponse;
|
||||
for (const citation of extractKimiCitations(data)) {
|
||||
collectedCitations.add(citation);
|
||||
}
|
||||
const choice = data.choices?.[0];
|
||||
const message = choice?.message;
|
||||
const text = extractKimiMessageText(message);
|
||||
const toolCalls = message?.tool_calls ?? [];
|
||||
|
||||
if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) {
|
||||
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: message?.content ?? "",
|
||||
...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
|
||||
tool_calls: toolCalls,
|
||||
});
|
||||
|
||||
const toolContent = buildKimiToolResultContent(data);
|
||||
let pushed = false;
|
||||
for (const toolCall of toolCalls) {
|
||||
const toolCallId = toolCall.id?.trim();
|
||||
if (!toolCallId) {
|
||||
continue;
|
||||
}
|
||||
pushed = true;
|
||||
messages.push({ role: "tool", tool_call_id: toolCallId, content: toolContent });
|
||||
}
|
||||
if (!pushed) {
|
||||
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
|
||||
}
|
||||
return { done: false };
|
||||
},
|
||||
);
|
||||
|
||||
if (next.done) {
|
||||
return { content: next.content, citations: next.citations };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: "Search completed but no final answer was produced.",
|
||||
citations: [...collectedCitations],
|
||||
};
|
||||
}
|
||||
|
||||
function createKimiSchema() {
|
||||
return Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
country: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
||||
language: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
||||
freshness: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
||||
date_after: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
||||
date_before: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
||||
});
|
||||
}
|
||||
|
||||
function createKimiToolDefinition(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
): WebSearchProviderToolDefinition {
|
||||
return {
|
||||
description:
|
||||
"Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.",
|
||||
parameters: createKimiSchema(),
|
||||
execute: async (args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
for (const name of ["country", "language", "freshness", "date_after", "date_before"]) {
|
||||
if (readStringParam(params, name)) {
|
||||
const label =
|
||||
name === "country"
|
||||
? "country filtering"
|
||||
: name === "language"
|
||||
? "language filtering"
|
||||
: name === "freshness"
|
||||
? "freshness filtering"
|
||||
: "date_after/date_before filtering";
|
||||
return {
|
||||
error: name.startsWith("date_") ? "unsupported_date_filter" : `unsupported_${name}`,
|
||||
message: `${label} is not supported by the kimi provider. Only Brave and Perplexity support ${name === "country" ? "country filtering" : name === "language" ? "language filtering" : name === "freshness" ? "freshness" : "date filtering"}.`,
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const kimiConfig = resolveKimiConfig(searchConfig);
|
||||
const apiKey = resolveKimiApiKey(kimiConfig);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error: "missing_kimi_api_key",
|
||||
message:
|
||||
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
const model = resolveKimiModel(kimiConfig);
|
||||
const baseUrl = resolveKimiBaseUrl(kimiConfig);
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"kimi",
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
baseUrl,
|
||||
model,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const result = await runKimiSearch({
|
||||
query,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
||||
});
|
||||
const payload = {
|
||||
query,
|
||||
provider: "kimi",
|
||||
model,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "kimi",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(result.content),
|
||||
citations: result.citations,
|
||||
};
|
||||
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
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,
|
||||
credentialPath: "tools.web.search.kimi.apiKey",
|
||||
inactiveSecretPaths: ["tools.web.search.kimi.apiKey"],
|
||||
getCredentialValue: (searchConfig) => {
|
||||
const kimi = searchConfig?.kimi;
|
||||
return kimi && typeof kimi === "object" && !Array.isArray(kimi)
|
||||
? (kimi as Record<string, unknown>).apiKey
|
||||
: undefined;
|
||||
},
|
||||
setCredentialValue: (searchConfigTarget, value) => {
|
||||
const scoped = searchConfigTarget.kimi;
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
searchConfigTarget.kimi = { apiKey: value };
|
||||
return;
|
||||
}
|
||||
(scoped as Record<string, unknown>).apiKey = value;
|
||||
},
|
||||
createTool: (ctx) =>
|
||||
createKimiToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveKimiApiKey,
|
||||
resolveKimiModel,
|
||||
resolveKimiBaseUrl,
|
||||
extractKimiCitations,
|
||||
} as const;
|
||||
@ -1,29 +1,11 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getScopedCredentialValue,
|
||||
setScopedCredentialValue,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { createPerplexityWebSearchProvider } from "./src/perplexity-web-search-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "perplexity",
|
||||
name: "Perplexity Plugin",
|
||||
description: "Bundled Perplexity plugin",
|
||||
register(api) {
|
||||
api.registerWebSearchProvider(
|
||||
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),
|
||||
}),
|
||||
);
|
||||
api.registerWebSearchProvider(createPerplexityWebSearchProvider());
|
||||
},
|
||||
});
|
||||
|
||||
701
extensions/perplexity/src/perplexity-web-search-provider.ts
Normal file
701
extensions/perplexity/src/perplexity-web-search-provider.ts
Normal file
@ -0,0 +1,701 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "../../../src/agents/tools/common.js";
|
||||
import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js";
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
MAX_SEARCH_COUNT,
|
||||
isoToPerplexityDate,
|
||||
normalizeFreshness,
|
||||
normalizeToIsoDate,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readProviderEnvValue,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
throwWebSearchApiError,
|
||||
withTrustedWebSearchEndpoint,
|
||||
writeCachedSearchPayload,
|
||||
} from "../../../src/agents/tools/web-search-provider-common.js";
|
||||
import type {
|
||||
WebSearchCredentialResolutionSource,
|
||||
WebSearchProviderPlugin,
|
||||
WebSearchProviderToolDefinition,
|
||||
} from "../../../src/plugins/types.js";
|
||||
import { wrapWebContent } from "../../../src/security/external-content.js";
|
||||
|
||||
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
|
||||
const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
|
||||
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
|
||||
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
||||
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
||||
|
||||
type PerplexityConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type PerplexityTransport = "search_api" | "chat_completions";
|
||||
type PerplexityBaseUrlHint = "direct" | "openrouter";
|
||||
|
||||
type PerplexitySearchResponse = {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
url_citation?: {
|
||||
url?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
citations?: string[];
|
||||
};
|
||||
|
||||
type PerplexitySearchApiResponse = {
|
||||
results?: Array<{
|
||||
title?: string;
|
||||
url?: string;
|
||||
snippet?: string;
|
||||
date?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig {
|
||||
const perplexity = searchConfig?.perplexity;
|
||||
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
|
||||
? (perplexity as PerplexityConfig)
|
||||
: {};
|
||||
}
|
||||
|
||||
function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | 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 resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
|
||||
apiKey?: string;
|
||||
source: "config" | "perplexity_env" | "openrouter_env" | "none";
|
||||
} {
|
||||
const fromConfig = readConfiguredSecretString(
|
||||
perplexity?.apiKey,
|
||||
"tools.web.search.perplexity.apiKey",
|
||||
);
|
||||
if (fromConfig) {
|
||||
return { apiKey: fromConfig, source: "config" };
|
||||
}
|
||||
const fromPerplexityEnv = readProviderEnvValue(["PERPLEXITY_API_KEY"]);
|
||||
if (fromPerplexityEnv) {
|
||||
return { apiKey: fromPerplexityEnv, source: "perplexity_env" };
|
||||
}
|
||||
const fromOpenRouterEnv = readProviderEnvValue(["OPENROUTER_API_KEY"]);
|
||||
if (fromOpenRouterEnv) {
|
||||
return { apiKey: fromOpenRouterEnv, source: "openrouter_env" };
|
||||
}
|
||||
return { apiKey: undefined, source: "none" };
|
||||
}
|
||||
|
||||
function resolvePerplexityBaseUrl(
|
||||
perplexity?: PerplexityConfig,
|
||||
authSource: "config" | "perplexity_env" | "openrouter_env" | "none" = "none",
|
||||
configuredKey?: string,
|
||||
): string {
|
||||
const fromConfig = typeof perplexity?.baseUrl === "string" ? perplexity.baseUrl.trim() : "";
|
||||
if (fromConfig) {
|
||||
return fromConfig;
|
||||
}
|
||||
if (authSource === "perplexity_env") {
|
||||
return PERPLEXITY_DIRECT_BASE_URL;
|
||||
}
|
||||
if (authSource === "openrouter_env") {
|
||||
return DEFAULT_PERPLEXITY_BASE_URL;
|
||||
}
|
||||
if (authSource === "config") {
|
||||
return inferPerplexityBaseUrlFromApiKey(configuredKey) === "openrouter"
|
||||
? DEFAULT_PERPLEXITY_BASE_URL
|
||||
: PERPLEXITY_DIRECT_BASE_URL;
|
||||
}
|
||||
return DEFAULT_PERPLEXITY_BASE_URL;
|
||||
}
|
||||
|
||||
function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
|
||||
const model = typeof perplexity?.model === "string" ? perplexity.model.trim() : "";
|
||||
return model || DEFAULT_PERPLEXITY_MODEL;
|
||||
}
|
||||
|
||||
function isDirectPerplexityBaseUrl(baseUrl: string): boolean {
|
||||
try {
|
||||
return new URL(baseUrl.trim()).hostname.toLowerCase() === "api.perplexity.ai";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePerplexityRequestModel(baseUrl: string, model: string): string {
|
||||
if (!isDirectPerplexityBaseUrl(baseUrl)) {
|
||||
return model;
|
||||
}
|
||||
return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model;
|
||||
}
|
||||
|
||||
function resolvePerplexityTransport(perplexity?: PerplexityConfig): {
|
||||
apiKey?: string;
|
||||
source: "config" | "perplexity_env" | "openrouter_env" | "none";
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
transport: PerplexityTransport;
|
||||
} {
|
||||
const auth = resolvePerplexityApiKey(perplexity);
|
||||
const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey);
|
||||
const model = resolvePerplexityModel(perplexity);
|
||||
const hasLegacyOverride = Boolean(
|
||||
(perplexity?.baseUrl && perplexity.baseUrl.trim()) ||
|
||||
(perplexity?.model && perplexity.model.trim()),
|
||||
);
|
||||
return {
|
||||
...auth,
|
||||
baseUrl,
|
||||
model,
|
||||
transport:
|
||||
hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api",
|
||||
};
|
||||
}
|
||||
|
||||
function extractPerplexityCitations(data: PerplexitySearchResponse): string[] {
|
||||
const topLevel = (data.citations ?? []).filter(
|
||||
(url): url is string => typeof url === "string" && Boolean(url.trim()),
|
||||
);
|
||||
if (topLevel.length > 0) {
|
||||
return [...new Set(topLevel)];
|
||||
}
|
||||
const citations: string[] = [];
|
||||
for (const choice of data.choices ?? []) {
|
||||
for (const annotation of choice.message?.annotations ?? []) {
|
||||
if (annotation.type !== "url_citation") {
|
||||
continue;
|
||||
}
|
||||
const url =
|
||||
typeof annotation.url_citation?.url === "string"
|
||||
? annotation.url_citation.url
|
||||
: typeof annotation.url === "string"
|
||||
? annotation.url
|
||||
: undefined;
|
||||
if (url?.trim()) {
|
||||
citations.push(url.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...new Set(citations)];
|
||||
}
|
||||
|
||||
async function runPerplexitySearchApi(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
count: number;
|
||||
timeoutSeconds: number;
|
||||
country?: string;
|
||||
searchDomainFilter?: string[];
|
||||
searchRecencyFilter?: string;
|
||||
searchLanguageFilter?: string[];
|
||||
searchAfterDate?: string;
|
||||
searchBeforeDate?: string;
|
||||
maxTokens?: number;
|
||||
maxTokensPerPage?: number;
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const body: Record<string, unknown> = {
|
||||
query: params.query,
|
||||
max_results: params.count,
|
||||
};
|
||||
if (params.country) body.country = params.country;
|
||||
if (params.searchDomainFilter?.length) body.search_domain_filter = params.searchDomainFilter;
|
||||
if (params.searchRecencyFilter) body.search_recency_filter = params.searchRecencyFilter;
|
||||
if (params.searchLanguageFilter?.length)
|
||||
body.search_language_filter = params.searchLanguageFilter;
|
||||
if (params.searchAfterDate) body.search_after_date = params.searchAfterDate;
|
||||
if (params.searchBeforeDate) body.search_before_date = params.searchBeforeDate;
|
||||
if (params.maxTokens !== undefined) body.max_tokens = params.maxTokens;
|
||||
if (params.maxTokensPerPage !== undefined) body.max_tokens_per_page = params.maxTokensPerPage;
|
||||
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: PERPLEXITY_SEARCH_ENDPOINT,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-Title": "OpenClaw Web Search",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
return await throwWebSearchApiError(res, "Perplexity Search");
|
||||
}
|
||||
const data = (await res.json()) as PerplexitySearchApiResponse;
|
||||
return (data.results ?? []).map((entry) => ({
|
||||
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
|
||||
url: entry.url ?? "",
|
||||
description: entry.snippet ? wrapWebContent(entry.snippet, "web_search") : "",
|
||||
published: entry.date ?? undefined,
|
||||
siteName: resolveSiteName(entry.url) || undefined,
|
||||
}));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function runPerplexitySearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
freshness?: string;
|
||||
}): Promise<{ content: string; citations: string[] }> {
|
||||
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
|
||||
const body: Record<string, unknown> = {
|
||||
model: resolvePerplexityRequestModel(params.baseUrl, params.model),
|
||||
messages: [{ role: "user", content: params.query }],
|
||||
};
|
||||
if (params.freshness) {
|
||||
body.search_recency_filter = params.freshness;
|
||||
}
|
||||
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-Title": "OpenClaw Web Search",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
return await throwWebSearchApiError(res, "Perplexity");
|
||||
}
|
||||
const data = (await res.json()) as PerplexitySearchResponse;
|
||||
return {
|
||||
content: data.choices?.[0]?.message?.content ?? "No response",
|
||||
citations: extractPerplexityCitations(data),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRuntimeTransport(params: {
|
||||
searchConfig?: Record<string, unknown>;
|
||||
resolvedKey?: string;
|
||||
keySource: WebSearchCredentialResolutionSource;
|
||||
fallbackEnvVar?: string;
|
||||
}): PerplexityTransport | undefined {
|
||||
const perplexity = params.searchConfig?.perplexity;
|
||||
const scoped =
|
||||
perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
|
||||
? (perplexity as { baseUrl?: string; model?: string })
|
||||
: undefined;
|
||||
const configuredBaseUrl = typeof scoped?.baseUrl === "string" ? scoped.baseUrl.trim() : "";
|
||||
const configuredModel = typeof scoped?.model === "string" ? scoped.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.resolvedKey) {
|
||||
return inferPerplexityBaseUrlFromApiKey(params.resolvedKey) === "openrouter"
|
||||
? DEFAULT_PERPLEXITY_BASE_URL
|
||||
: PERPLEXITY_DIRECT_BASE_URL;
|
||||
}
|
||||
return DEFAULT_PERPLEXITY_BASE_URL;
|
||||
})();
|
||||
return configuredBaseUrl || configuredModel || !isDirectPerplexityBaseUrl(baseUrl)
|
||||
? "chat_completions"
|
||||
: "search_api";
|
||||
}
|
||||
|
||||
function createPerplexitySchema(transport?: PerplexityTransport) {
|
||||
const querySchema = {
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
freshness: Type.Optional(
|
||||
Type.String({ description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'." }),
|
||||
),
|
||||
};
|
||||
if (transport === "chat_completions") {
|
||||
return Type.Object(querySchema);
|
||||
}
|
||||
return Type.Object({
|
||||
...querySchema,
|
||||
country: Type.Optional(
|
||||
Type.String({ description: "Native Perplexity Search API only. 2-letter country code." }),
|
||||
),
|
||||
language: Type.Optional(
|
||||
Type.String({ description: "Native Perplexity Search API only. ISO 639-1 language code." }),
|
||||
),
|
||||
date_after: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).",
|
||||
}),
|
||||
),
|
||||
date_before: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).",
|
||||
}),
|
||||
),
|
||||
domain_filter: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Native Perplexity Search API only. Domain filter (max 20).",
|
||||
}),
|
||||
),
|
||||
max_tokens: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Native Perplexity Search API only. Total content budget across all results.",
|
||||
minimum: 1,
|
||||
maximum: 1000000,
|
||||
}),
|
||||
),
|
||||
max_tokens_per_page: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Native Perplexity Search API only. Max tokens extracted per page.",
|
||||
minimum: 1,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function createPerplexityToolDefinition(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
runtimeTransport?: PerplexityTransport,
|
||||
): WebSearchProviderToolDefinition {
|
||||
const perplexityConfig = resolvePerplexityConfig(searchConfig);
|
||||
const schemaTransport =
|
||||
runtimeTransport ??
|
||||
(perplexityConfig.baseUrl || perplexityConfig.model ? "chat_completions" : undefined);
|
||||
|
||||
return {
|
||||
description:
|
||||
schemaTransport === "chat_completions"
|
||||
? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search."
|
||||
: "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.",
|
||||
parameters: createPerplexitySchema(schemaTransport),
|
||||
execute: async (args) => {
|
||||
const runtime = resolvePerplexityTransport(perplexityConfig);
|
||||
if (!runtime.apiKey) {
|
||||
return {
|
||||
error: "missing_perplexity_api_key",
|
||||
message:
|
||||
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const params = args as Record<string, unknown>;
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
const rawFreshness = readStringParam(params, "freshness");
|
||||
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "perplexity") : undefined;
|
||||
if (rawFreshness && !freshness) {
|
||||
return {
|
||||
error: "invalid_freshness",
|
||||
message: "freshness must be day, week, month, or year.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const structured = runtime.transport === "search_api";
|
||||
const country = readStringParam(params, "country");
|
||||
const language = readStringParam(params, "language");
|
||||
const rawDateAfter = readStringParam(params, "date_after");
|
||||
const rawDateBefore = readStringParam(params, "date_before");
|
||||
const domainFilter = readStringArrayParam(params, "domain_filter");
|
||||
const maxTokens = readNumberParam(params, "max_tokens", { integer: true });
|
||||
const maxTokensPerPage = readNumberParam(params, "max_tokens_per_page", { integer: true });
|
||||
|
||||
if (!structured) {
|
||||
if (country) {
|
||||
return {
|
||||
error: "unsupported_country",
|
||||
message:
|
||||
"country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (language) {
|
||||
return {
|
||||
error: "unsupported_language",
|
||||
message:
|
||||
"language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (rawDateAfter || rawDateBefore) {
|
||||
return {
|
||||
error: "unsupported_date_filter",
|
||||
message:
|
||||
"date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (domainFilter?.length) {
|
||||
return {
|
||||
error: "unsupported_domain_filter",
|
||||
message:
|
||||
"domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (maxTokens !== undefined || maxTokensPerPage !== undefined) {
|
||||
return {
|
||||
error: "unsupported_content_budget",
|
||||
message:
|
||||
"max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (language && !/^[a-z]{2}$/i.test(language)) {
|
||||
return {
|
||||
error: "invalid_language",
|
||||
message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
|
||||
return {
|
||||
error: "conflicting_time_filters",
|
||||
message:
|
||||
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
|
||||
const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined;
|
||||
if (rawDateAfter && !dateAfter) {
|
||||
return {
|
||||
error: "invalid_date",
|
||||
message: "date_after must be YYYY-MM-DD format.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (rawDateBefore && !dateBefore) {
|
||||
return {
|
||||
error: "invalid_date",
|
||||
message: "date_before must be YYYY-MM-DD format.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (dateAfter && dateBefore && dateAfter > dateBefore) {
|
||||
return {
|
||||
error: "invalid_date_range",
|
||||
message: "date_after must be before date_before.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (domainFilter?.length) {
|
||||
const hasDeny = domainFilter.some((entry) => entry.startsWith("-"));
|
||||
const hasAllow = domainFilter.some((entry) => !entry.startsWith("-"));
|
||||
if (hasDeny && hasAllow) {
|
||||
return {
|
||||
error: "invalid_domain_filter",
|
||||
message:
|
||||
"domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (domainFilter.length > 20) {
|
||||
return {
|
||||
error: "invalid_domain_filter",
|
||||
message: "domain_filter supports a maximum of 20 domains.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"perplexity",
|
||||
runtime.transport,
|
||||
runtime.baseUrl,
|
||||
runtime.model,
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
country,
|
||||
language,
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
domainFilter?.join(","),
|
||||
maxTokens,
|
||||
maxTokensPerPage,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
|
||||
const payload =
|
||||
runtime.transport === "chat_completions"
|
||||
? {
|
||||
query,
|
||||
provider: "perplexity",
|
||||
model: runtime.model,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "perplexity",
|
||||
wrapped: true,
|
||||
},
|
||||
...(await (async () => {
|
||||
const result = await runPerplexitySearch({
|
||||
query,
|
||||
apiKey: runtime.apiKey!,
|
||||
baseUrl: runtime.baseUrl,
|
||||
model: runtime.model,
|
||||
timeoutSeconds,
|
||||
freshness,
|
||||
});
|
||||
return {
|
||||
content: wrapWebContent(result.content, "web_search"),
|
||||
citations: result.citations,
|
||||
};
|
||||
})()),
|
||||
}
|
||||
: {
|
||||
query,
|
||||
provider: "perplexity",
|
||||
count: 0,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "perplexity",
|
||||
wrapped: true,
|
||||
},
|
||||
results: await runPerplexitySearchApi({
|
||||
query,
|
||||
apiKey: runtime.apiKey!,
|
||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
timeoutSeconds,
|
||||
country: country ?? undefined,
|
||||
searchDomainFilter: domainFilter,
|
||||
searchRecencyFilter: freshness,
|
||||
searchLanguageFilter: language ? [language] : undefined,
|
||||
searchAfterDate: dateAfter ? isoToPerplexityDate(dateAfter) : undefined,
|
||||
searchBeforeDate: dateBefore ? isoToPerplexityDate(dateBefore) : undefined,
|
||||
maxTokens: maxTokens ?? undefined,
|
||||
maxTokensPerPage: maxTokensPerPage ?? undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
if (Array.isArray((payload as { results?: unknown[] }).results)) {
|
||||
(payload as { count: number }).count = (payload as { results: unknown[] }).results.length;
|
||||
(payload as { tookMs: number }).tookMs = Date.now() - start;
|
||||
} else {
|
||||
(payload as { tookMs: number }).tookMs = Date.now() - start;
|
||||
}
|
||||
|
||||
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
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,
|
||||
credentialPath: "tools.web.search.perplexity.apiKey",
|
||||
inactiveSecretPaths: ["tools.web.search.perplexity.apiKey"],
|
||||
getCredentialValue: (searchConfig) => {
|
||||
const perplexity = searchConfig?.perplexity;
|
||||
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
|
||||
? (perplexity as Record<string, unknown>).apiKey
|
||||
: undefined;
|
||||
},
|
||||
setCredentialValue: (searchConfigTarget, value) => {
|
||||
const scoped = searchConfigTarget.perplexity;
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
searchConfigTarget.perplexity = { apiKey: value };
|
||||
return;
|
||||
}
|
||||
(scoped as Record<string, unknown>).apiKey = value;
|
||||
},
|
||||
resolveRuntimeMetadata: (ctx) => ({
|
||||
perplexityTransport: resolveRuntimeTransport({
|
||||
searchConfig: ctx.searchConfig,
|
||||
resolvedKey: ctx.resolvedCredential?.value,
|
||||
keySource: ctx.resolvedCredential?.source ?? "missing",
|
||||
fallbackEnvVar: ctx.resolvedCredential?.fallbackEnvVar,
|
||||
}),
|
||||
}),
|
||||
createTool: (ctx) =>
|
||||
createPerplexityToolDefinition(
|
||||
ctx.searchConfig as SearchConfigRecord | undefined,
|
||||
ctx.runtimeMetadata?.perplexityTransport as PerplexityTransport | undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
inferPerplexityBaseUrlFromApiKey,
|
||||
resolvePerplexityBaseUrl,
|
||||
resolvePerplexityModel,
|
||||
resolvePerplexityTransport,
|
||||
isDirectPerplexityBaseUrl,
|
||||
resolvePerplexityRequestModel,
|
||||
resolvePerplexityApiKey,
|
||||
normalizeToIsoDate,
|
||||
isoToPerplexityDate,
|
||||
} as const;
|
||||
4
extensions/perplexity/web-search-provider.ts
Normal file
4
extensions/perplexity/web-search-provider.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export {
|
||||
__testing,
|
||||
createPerplexityWebSearchProvider,
|
||||
} from "./src/perplexity-web-search-provider.js";
|
||||
@ -1,12 +1,8 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-models";
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getScopedCredentialValue,
|
||||
setScopedCredentialValue,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
import { createGrokWebSearchProvider } from "./src/grok-web-search-provider.js";
|
||||
|
||||
const PROVIDER_ID = "xai";
|
||||
const XAI_MODERN_MODEL_PREFIXES = ["grok-4"] as const;
|
||||
@ -51,20 +47,6 @@ export default definePluginEntry({
|
||||
isModernModelRef: ({ provider, modelId }) =>
|
||||
normalizeProviderId(provider) === "xai" ? matchesModernXaiModel(modelId) : undefined,
|
||||
});
|
||||
api.registerWebSearchProvider(
|
||||
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),
|
||||
}),
|
||||
);
|
||||
api.registerWebSearchProvider(createGrokWebSearchProvider());
|
||||
},
|
||||
});
|
||||
|
||||
303
extensions/xai/src/grok-web-search-provider.ts
Normal file
303
extensions/xai/src/grok-web-search-provider.ts
Normal file
@ -0,0 +1,303 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js";
|
||||
import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js";
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
MAX_SEARCH_COUNT,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readProviderEnvValue,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
withTrustedWebSearchEndpoint,
|
||||
writeCachedSearchPayload,
|
||||
} from "../../../src/agents/tools/web-search-provider-common.js";
|
||||
import type {
|
||||
WebSearchProviderPlugin,
|
||||
WebSearchProviderToolDefinition,
|
||||
} from "../../../src/plugins/types.js";
|
||||
import { wrapWebContent } from "../../../src/security/external-content.js";
|
||||
|
||||
const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses";
|
||||
const DEFAULT_GROK_MODEL = "grok-4-1-fast";
|
||||
|
||||
type GrokConfig = {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
inlineCitations?: boolean;
|
||||
};
|
||||
|
||||
type GrokSearchResponse = {
|
||||
output?: Array<{
|
||||
type?: string;
|
||||
role?: string;
|
||||
text?: string;
|
||||
content?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
start_index?: number;
|
||||
end_index?: number;
|
||||
}>;
|
||||
}>;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
start_index?: number;
|
||||
end_index?: number;
|
||||
}>;
|
||||
}>;
|
||||
output_text?: string;
|
||||
citations?: string[];
|
||||
inline_citations?: Array<{
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function resolveGrokConfig(searchConfig?: SearchConfigRecord): GrokConfig {
|
||||
const grok = searchConfig?.grok;
|
||||
return grok && typeof grok === "object" && !Array.isArray(grok) ? (grok as GrokConfig) : {};
|
||||
}
|
||||
|
||||
function resolveGrokApiKey(grok?: GrokConfig): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(grok?.apiKey, "tools.web.search.grok.apiKey") ??
|
||||
readProviderEnvValue(["XAI_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGrokModel(grok?: GrokConfig): string {
|
||||
const model = typeof grok?.model === "string" ? grok.model.trim() : "";
|
||||
return model || DEFAULT_GROK_MODEL;
|
||||
}
|
||||
|
||||
function resolveGrokInlineCitations(grok?: GrokConfig): boolean {
|
||||
return grok?.inlineCitations === true;
|
||||
}
|
||||
|
||||
function extractGrokContent(data: GrokSearchResponse): {
|
||||
text: string | undefined;
|
||||
annotationCitations: string[];
|
||||
} {
|
||||
for (const output of data.output ?? []) {
|
||||
if (output.type === "message") {
|
||||
for (const block of output.content ?? []) {
|
||||
if (block.type === "output_text" && typeof block.text === "string" && block.text) {
|
||||
const urls = (block.annotations ?? [])
|
||||
.filter(
|
||||
(annotation) =>
|
||||
annotation.type === "url_citation" && typeof annotation.url === "string",
|
||||
)
|
||||
.map((annotation) => annotation.url as string);
|
||||
return { text: block.text, annotationCitations: [...new Set(urls)] };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (output.type === "output_text" && typeof output.text === "string" && output.text) {
|
||||
const urls = (Array.isArray(output.annotations) ? output.annotations : [])
|
||||
.filter(
|
||||
(annotation) => annotation.type === "url_citation" && typeof annotation.url === "string",
|
||||
)
|
||||
.map((annotation) => annotation.url as string);
|
||||
return { text: output.text, annotationCitations: [...new Set(urls)] };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: typeof data.output_text === "string" ? data.output_text : undefined,
|
||||
annotationCitations: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function runGrokSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
inlineCitations: boolean;
|
||||
}): Promise<{
|
||||
content: string;
|
||||
citations: string[];
|
||||
inlineCitations?: GrokSearchResponse["inline_citations"];
|
||||
}> {
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: XAI_API_ENDPOINT,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
input: [{ role: "user", content: params.query }],
|
||||
tools: [{ type: "web_search" }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`xAI API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as GrokSearchResponse;
|
||||
const { text, annotationCitations } = extractGrokContent(data);
|
||||
return {
|
||||
content: text ?? "No response",
|
||||
citations: (data.citations ?? []).length > 0 ? data.citations! : annotationCitations,
|
||||
inlineCitations: data.inline_citations,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGrokSchema() {
|
||||
return Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
country: Type.Optional(Type.String({ description: "Not supported by Grok." })),
|
||||
language: Type.Optional(Type.String({ description: "Not supported by Grok." })),
|
||||
freshness: Type.Optional(Type.String({ description: "Not supported by Grok." })),
|
||||
date_after: Type.Optional(Type.String({ description: "Not supported by Grok." })),
|
||||
date_before: Type.Optional(Type.String({ description: "Not supported by Grok." })),
|
||||
});
|
||||
}
|
||||
|
||||
function createGrokToolDefinition(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
): WebSearchProviderToolDefinition {
|
||||
return {
|
||||
description:
|
||||
"Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.",
|
||||
parameters: createGrokSchema(),
|
||||
execute: async (args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
for (const name of ["country", "language", "freshness", "date_after", "date_before"]) {
|
||||
if (readStringParam(params, name)) {
|
||||
const label =
|
||||
name === "country"
|
||||
? "country filtering"
|
||||
: name === "language"
|
||||
? "language filtering"
|
||||
: name === "freshness"
|
||||
? "freshness filtering"
|
||||
: "date_after/date_before filtering";
|
||||
return {
|
||||
error: name.startsWith("date_") ? "unsupported_date_filter" : `unsupported_${name}`,
|
||||
message: `${label} is not supported by the grok provider. Only Brave and Perplexity support ${name === "country" ? "country filtering" : name === "language" ? "language filtering" : name === "freshness" ? "freshness" : "date filtering"}.`,
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const grokConfig = resolveGrokConfig(searchConfig);
|
||||
const apiKey = resolveGrokApiKey(grokConfig);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error: "missing_xai_api_key",
|
||||
message:
|
||||
"web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
const model = resolveGrokModel(grokConfig);
|
||||
const inlineCitations = resolveGrokInlineCitations(grokConfig);
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"grok",
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
model,
|
||||
inlineCitations,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const result = await runGrokSearch({
|
||||
query,
|
||||
apiKey,
|
||||
model,
|
||||
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
||||
inlineCitations,
|
||||
});
|
||||
const payload = {
|
||||
query,
|
||||
provider: "grok",
|
||||
model,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "grok",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(result.content),
|
||||
citations: result.citations,
|
||||
inlineCitations: result.inlineCitations,
|
||||
};
|
||||
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createGrokWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
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,
|
||||
credentialPath: "tools.web.search.grok.apiKey",
|
||||
inactiveSecretPaths: ["tools.web.search.grok.apiKey"],
|
||||
getCredentialValue: (searchConfig) => {
|
||||
const grok = searchConfig?.grok;
|
||||
return grok && typeof grok === "object" && !Array.isArray(grok)
|
||||
? (grok as Record<string, unknown>).apiKey
|
||||
: undefined;
|
||||
},
|
||||
setCredentialValue: (searchConfigTarget, value) => {
|
||||
const scoped = searchConfigTarget.grok;
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
searchConfigTarget.grok = { apiKey: value };
|
||||
return;
|
||||
}
|
||||
(scoped as Record<string, unknown>).apiKey = value;
|
||||
},
|
||||
createTool: (ctx) =>
|
||||
createGrokToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveGrokApiKey,
|
||||
resolveGrokModel,
|
||||
resolveGrokInlineCitations,
|
||||
extractGrokContent,
|
||||
} as const;
|
||||
@ -541,6 +541,7 @@
|
||||
"lint:fix": "oxlint --type-aware --fix && pnpm format",
|
||||
"lint:plugins:no-extension-src-imports": "node --import tsx scripts/check-no-extension-src-imports.ts",
|
||||
"lint:plugins:no-extension-test-core-imports": "node --import tsx scripts/check-no-extension-test-core-imports.ts",
|
||||
"lint:plugins:no-extension-imports": "node scripts/check-plugin-extension-import-boundary.mjs",
|
||||
"lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.ts",
|
||||
"lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs",
|
||||
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
|
||||
@ -548,6 +549,7 @@
|
||||
"lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",
|
||||
"lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs",
|
||||
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
|
||||
"lint:web-search-provider-boundaries": "node scripts/check-web-search-provider-boundaries.mjs",
|
||||
"lint:webhook:no-low-level-body-read": "node scripts/check-webhook-auth-body-order.mjs",
|
||||
"mac:open": "open dist/OpenClaw.app",
|
||||
"mac:package": "bash scripts/package-mac-app.sh",
|
||||
|
||||
302
scripts/check-plugin-extension-import-boundary.mjs
Normal file
302
scripts/check-plugin-extension-import-boundary.mjs
Normal file
@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import {
|
||||
collectTypeScriptFilesFromRoots,
|
||||
resolveSourceRoots,
|
||||
runAsScript,
|
||||
toLine,
|
||||
} from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const scanRoots = resolveSourceRoots(repoRoot, ["src/plugins"]);
|
||||
const baselinePath = path.join(
|
||||
repoRoot,
|
||||
"test",
|
||||
"fixtures",
|
||||
"plugin-extension-import-boundary-inventory.json",
|
||||
);
|
||||
|
||||
const bundledWebSearchProviders = new Set([
|
||||
"brave",
|
||||
"firecrawl",
|
||||
"gemini",
|
||||
"grok",
|
||||
"kimi",
|
||||
"perplexity",
|
||||
]);
|
||||
const bundledWebSearchPluginIds = new Set([
|
||||
"brave",
|
||||
"firecrawl",
|
||||
"google",
|
||||
"moonshot",
|
||||
"perplexity",
|
||||
"xai",
|
||||
]);
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function compareEntries(left, right) {
|
||||
return (
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.kind.localeCompare(right.kind) ||
|
||||
left.specifier.localeCompare(right.specifier) ||
|
||||
left.reason.localeCompare(right.reason)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSpecifier(specifier, importerFile) {
|
||||
if (specifier.startsWith(".")) {
|
||||
return normalizePath(path.resolve(path.dirname(importerFile), specifier));
|
||||
}
|
||||
if (specifier.startsWith("/")) {
|
||||
return normalizePath(specifier);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyResolvedExtensionReason(kind, resolvedPath) {
|
||||
const verb =
|
||||
kind === "export"
|
||||
? "re-exports"
|
||||
: kind === "dynamic-import"
|
||||
? "dynamically imports"
|
||||
: "imports";
|
||||
if (/^extensions\/[^/]+\/src\//.test(resolvedPath)) {
|
||||
return `${verb} extension implementation from src/plugins`;
|
||||
}
|
||||
if (/^extensions\/[^/]+\/index\.[^/]+$/.test(resolvedPath)) {
|
||||
return `${verb} extension entrypoint from src/plugins`;
|
||||
}
|
||||
return `${verb} extension-owned file from src/plugins`;
|
||||
}
|
||||
|
||||
function pushEntry(entries, entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
function scanImportBoundaryViolations(sourceFile, filePath) {
|
||||
const entries = [];
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const specifier = node.moduleSpecifier.text;
|
||||
const resolvedPath = resolveSpecifier(specifier, filePath);
|
||||
if (resolvedPath?.startsWith("extensions/")) {
|
||||
pushEntry(entries, {
|
||||
file: normalizePath(filePath),
|
||||
line: toLine(sourceFile, node.moduleSpecifier),
|
||||
kind: "import",
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: classifyResolvedExtensionReason("import", resolvedPath),
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
const specifier = node.moduleSpecifier.text;
|
||||
const resolvedPath = resolveSpecifier(specifier, filePath);
|
||||
if (resolvedPath?.startsWith("extensions/")) {
|
||||
pushEntry(entries, {
|
||||
file: normalizePath(filePath),
|
||||
line: toLine(sourceFile, node.moduleSpecifier),
|
||||
kind: "export",
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: classifyResolvedExtensionReason("export", resolvedPath),
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
node.arguments.length === 1 &&
|
||||
ts.isStringLiteral(node.arguments[0])
|
||||
) {
|
||||
const specifier = node.arguments[0].text;
|
||||
const resolvedPath = resolveSpecifier(specifier, filePath);
|
||||
if (resolvedPath?.startsWith("extensions/")) {
|
||||
pushEntry(entries, {
|
||||
file: normalizePath(filePath),
|
||||
line: toLine(sourceFile, node.arguments[0]),
|
||||
kind: "dynamic-import",
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: classifyResolvedExtensionReason("dynamic-import", resolvedPath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return entries;
|
||||
}
|
||||
|
||||
function scanWebSearchRegistrySmells(sourceFile, filePath) {
|
||||
const relativeFile = normalizePath(filePath);
|
||||
if (relativeFile !== "src/plugins/web-search-providers.ts") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
const lines = sourceFile.text.split(/\r?\n/);
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const lineNumber = index + 1;
|
||||
|
||||
if (line.includes("web-search-plugin-factory.js")) {
|
||||
pushEntry(entries, {
|
||||
file: relativeFile,
|
||||
line: lineNumber,
|
||||
kind: "registry-smell",
|
||||
specifier: "../agents/tools/web-search-plugin-factory.js",
|
||||
resolvedPath: "src/agents/tools/web-search-plugin-factory.js",
|
||||
reason: "imports core-owned web search provider factory into plugin registry",
|
||||
});
|
||||
}
|
||||
|
||||
const pluginMatch = line.match(/pluginId:\s*"([^"]+)"/);
|
||||
if (pluginMatch && bundledWebSearchPluginIds.has(pluginMatch[1])) {
|
||||
pushEntry(entries, {
|
||||
file: relativeFile,
|
||||
line: lineNumber,
|
||||
kind: "registry-smell",
|
||||
specifier: pluginMatch[1],
|
||||
resolvedPath: relativeFile,
|
||||
reason: "hardcodes bundled web search plugin ownership in core registry",
|
||||
});
|
||||
}
|
||||
|
||||
const providerMatch = line.match(/id:\s*"(brave|firecrawl|gemini|grok|kimi|perplexity)"/);
|
||||
if (providerMatch && bundledWebSearchProviders.has(providerMatch[1])) {
|
||||
pushEntry(entries, {
|
||||
file: relativeFile,
|
||||
line: lineNumber,
|
||||
kind: "registry-smell",
|
||||
specifier: providerMatch[1],
|
||||
resolvedPath: relativeFile,
|
||||
reason: "hardcodes bundled web search provider metadata in core registry",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function shouldSkipFile(filePath) {
|
||||
const relativeFile = normalizePath(filePath);
|
||||
return relativeFile.startsWith("src/plugins/contracts/");
|
||||
}
|
||||
|
||||
export async function collectPluginExtensionImportBoundaryInventory() {
|
||||
const files = (await collectTypeScriptFilesFromRoots(scanRoots))
|
||||
.filter((filePath) => !shouldSkipFile(filePath))
|
||||
.toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
|
||||
|
||||
const inventory = [];
|
||||
for (const filePath of files) {
|
||||
const source = await fs.readFile(filePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
inventory.push(...scanImportBoundaryViolations(sourceFile, filePath));
|
||||
inventory.push(...scanWebSearchRegistrySmells(sourceFile, filePath));
|
||||
}
|
||||
|
||||
return inventory.toSorted(compareEntries);
|
||||
}
|
||||
|
||||
export async function readExpectedInventory() {
|
||||
return JSON.parse(await fs.readFile(baselinePath, "utf8"));
|
||||
}
|
||||
|
||||
export function diffInventory(expected, actual) {
|
||||
const expectedKeys = new Set(expected.map((entry) => JSON.stringify(entry)));
|
||||
const actualKeys = new Set(actual.map((entry) => JSON.stringify(entry)));
|
||||
return {
|
||||
missing: expected
|
||||
.filter((entry) => !actualKeys.has(JSON.stringify(entry)))
|
||||
.toSorted(compareEntries),
|
||||
unexpected: actual
|
||||
.filter((entry) => !expectedKeys.has(JSON.stringify(entry)))
|
||||
.toSorted(compareEntries),
|
||||
};
|
||||
}
|
||||
|
||||
function formatInventoryHuman(inventory) {
|
||||
if (inventory.length === 0) {
|
||||
return "Rule: src/plugins/** must not import extensions/**\nNo plugin import boundary violations found.";
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"Rule: src/plugins/** must not import extensions/**",
|
||||
"Plugin extension import boundary inventory:",
|
||||
];
|
||||
let activeFile = "";
|
||||
for (const entry of inventory) {
|
||||
if (entry.file !== activeFile) {
|
||||
activeFile = entry.file;
|
||||
lines.push(activeFile);
|
||||
}
|
||||
lines.push(` - line ${entry.line} [${entry.kind}] ${entry.reason}`);
|
||||
lines.push(` specifier: ${entry.specifier}`);
|
||||
lines.push(` resolved: ${entry.resolvedPath}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatEntry(entry) {
|
||||
return `${entry.file}:${entry.line} [${entry.kind}] ${entry.reason} (${entry.specifier} -> ${entry.resolvedPath})`;
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const json = argv.includes("--json");
|
||||
const actual = await collectPluginExtensionImportBoundaryInventory();
|
||||
const expected = await readExpectedInventory();
|
||||
const { missing, unexpected } = diffInventory(expected, actual);
|
||||
const matchesBaseline = missing.length === 0 && unexpected.length === 0;
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify(actual, null, 2)}\n`);
|
||||
} else {
|
||||
console.log(formatInventoryHuman(actual));
|
||||
console.log(
|
||||
matchesBaseline
|
||||
? `Baseline matches (${actual.length} entries).`
|
||||
: `Baseline mismatch (${unexpected.length} unexpected, ${missing.length} missing).`,
|
||||
);
|
||||
if (!matchesBaseline) {
|
||||
if (unexpected.length > 0) {
|
||||
console.error("Unexpected entries:");
|
||||
for (const entry of unexpected) {
|
||||
console.error(`- ${formatEntry(entry)}`);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
console.error("Missing baseline entries:");
|
||||
for (const entry of missing) {
|
||||
console.error(`- ${formatEntry(entry)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchesBaseline) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runAsScript(import.meta.url, main);
|
||||
331
scripts/check-web-search-provider-boundaries.mjs
Normal file
331
scripts/check-web-search-provider-boundaries.mjs
Normal file
@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runAsScript } from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const baselinePath = path.join(
|
||||
repoRoot,
|
||||
"test",
|
||||
"fixtures",
|
||||
"web-search-provider-boundary-inventory.json",
|
||||
);
|
||||
|
||||
const scanRoots = ["src", "test", "scripts"];
|
||||
const scanExtensions = new Set([".ts", ".js", ".mjs", ".cjs"]);
|
||||
const ignoredDirNames = new Set([
|
||||
".artifacts",
|
||||
".git",
|
||||
".turbo",
|
||||
"build",
|
||||
"coverage",
|
||||
"dist",
|
||||
"extensions",
|
||||
"node_modules",
|
||||
]);
|
||||
|
||||
const bundledProviderPluginToSearchProvider = new Map([
|
||||
["brave", "brave"],
|
||||
["firecrawl", "firecrawl"],
|
||||
["google", "gemini"],
|
||||
["moonshot", "kimi"],
|
||||
["perplexity", "perplexity"],
|
||||
["xai", "grok"],
|
||||
]);
|
||||
|
||||
const providerIds = new Set([
|
||||
"brave",
|
||||
"firecrawl",
|
||||
"gemini",
|
||||
"grok",
|
||||
"kimi",
|
||||
"perplexity",
|
||||
"shared",
|
||||
]);
|
||||
|
||||
const allowedGenericFiles = new Set([
|
||||
"src/agents/tools/web-search-core.ts",
|
||||
"src/agents/tools/web-search.ts",
|
||||
"src/secrets/runtime-web-tools.ts",
|
||||
]);
|
||||
|
||||
const ignoredFiles = new Set([
|
||||
"scripts/check-plugin-extension-import-boundary.mjs",
|
||||
"scripts/check-web-search-provider-boundaries.mjs",
|
||||
"test/web-search-provider-boundary.test.ts",
|
||||
]);
|
||||
|
||||
function normalizeRelativePath(filePath) {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
async function walkFiles(rootDir) {
|
||||
const out = [];
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(rootDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return out;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(rootDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (ignoredDirNames.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
out.push(...(await walkFiles(entryPath)));
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (!scanExtensions.has(path.extname(entry.name))) {
|
||||
continue;
|
||||
}
|
||||
out.push(entryPath);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function compareInventoryEntries(left, right) {
|
||||
return (
|
||||
left.provider.localeCompare(right.provider) ||
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.reason.localeCompare(right.reason)
|
||||
);
|
||||
}
|
||||
|
||||
function pushEntry(inventory, entry) {
|
||||
if (!providerIds.has(entry.provider)) {
|
||||
throw new Error(`Unknown provider id in boundary inventory: ${entry.provider}`);
|
||||
}
|
||||
inventory.push(entry);
|
||||
}
|
||||
|
||||
function scanWebSearchProviderRegistry(lines, relativeFile, inventory) {
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const lineNumber = index + 1;
|
||||
|
||||
if (line.includes("firecrawl-search-provider.js")) {
|
||||
pushEntry(inventory, {
|
||||
provider: "shared",
|
||||
file: relativeFile,
|
||||
line: lineNumber,
|
||||
reason: "imports extension web search provider implementation into core registry",
|
||||
});
|
||||
}
|
||||
|
||||
if (line.includes("web-search-plugin-factory.js")) {
|
||||
pushEntry(inventory, {
|
||||
provider: "shared",
|
||||
file: relativeFile,
|
||||
line: lineNumber,
|
||||
reason: "imports shared web search provider registration helper into core registry",
|
||||
});
|
||||
}
|
||||
|
||||
const pluginMatch = line.match(/pluginId:\s*"([^"]+)"/);
|
||||
const providerFromPlugin = pluginMatch
|
||||
? bundledProviderPluginToSearchProvider.get(pluginMatch[1])
|
||||
: undefined;
|
||||
if (providerFromPlugin) {
|
||||
pushEntry(inventory, {
|
||||
provider: providerFromPlugin,
|
||||
file: relativeFile,
|
||||
line: lineNumber,
|
||||
reason: "hardcodes bundled web search plugin ownership in core registry",
|
||||
});
|
||||
}
|
||||
|
||||
const providerMatch = line.match(/id:\s*"(brave|firecrawl|gemini|grok|kimi|perplexity)"/);
|
||||
if (providerMatch) {
|
||||
pushEntry(inventory, {
|
||||
provider: providerMatch[1],
|
||||
file: relativeFile,
|
||||
line: lineNumber,
|
||||
reason: "hardcodes bundled web search provider id in core registry",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanOnboardSearch(lines, relativeFile, inventory) {
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const lineNumber = index + 1;
|
||||
|
||||
if (line.includes("web-search-providers.js")) {
|
||||
pushEntry(inventory, {
|
||||
provider: "shared",
|
||||
file: relativeFile,
|
||||
line: lineNumber,
|
||||
reason: "imports bundled web search registry into core onboarding flow",
|
||||
});
|
||||
}
|
||||
|
||||
if (line.includes("const SEARCH_PROVIDER_IDS = [")) {
|
||||
for (const provider of ["brave", "firecrawl", "gemini", "grok", "kimi", "perplexity"]) {
|
||||
if (!line.includes(`"${provider}"`)) {
|
||||
continue;
|
||||
}
|
||||
pushEntry(inventory, {
|
||||
provider,
|
||||
file: relativeFile,
|
||||
line: lineNumber,
|
||||
reason: "hardcodes bundled web search provider inventory in core onboarding flow",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
line.includes('provider !== "firecrawl"') ||
|
||||
line.includes('enablePluginInConfig(next, "firecrawl")')
|
||||
) {
|
||||
pushEntry(inventory, {
|
||||
provider: "firecrawl",
|
||||
file: relativeFile,
|
||||
line: lineNumber,
|
||||
reason: "hardcodes provider-specific plugin enablement coupling in core onboarding flow",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanGenericCoreImports(lines, relativeFile, inventory) {
|
||||
if (allowedGenericFiles.has(relativeFile)) {
|
||||
return;
|
||||
}
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const lineNumber = index + 1;
|
||||
if (line.includes("web-search-providers.js")) {
|
||||
pushEntry(inventory, {
|
||||
provider: "shared",
|
||||
file: relativeFile,
|
||||
line: lineNumber,
|
||||
reason: "imports bundled web search registry outside allowed generic plumbing",
|
||||
});
|
||||
}
|
||||
if (line.includes("web-search-plugin-factory.js")) {
|
||||
pushEntry(inventory, {
|
||||
provider: "shared",
|
||||
file: relativeFile,
|
||||
line: lineNumber,
|
||||
reason: "imports web search provider registration helper outside extensions",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectWebSearchProviderBoundaryInventory() {
|
||||
const inventory = [];
|
||||
const files = (
|
||||
await Promise.all(scanRoots.map(async (root) => await walkFiles(path.join(repoRoot, root))))
|
||||
)
|
||||
.flat()
|
||||
.toSorted((left, right) =>
|
||||
normalizeRelativePath(left).localeCompare(normalizeRelativePath(right)),
|
||||
);
|
||||
|
||||
for (const filePath of files) {
|
||||
const relativeFile = normalizeRelativePath(filePath);
|
||||
if (ignoredFiles.has(relativeFile)) {
|
||||
continue;
|
||||
}
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
if (relativeFile === "src/plugins/web-search-providers.ts") {
|
||||
scanWebSearchProviderRegistry(lines, relativeFile, inventory);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (relativeFile === "src/commands/onboard-search.ts") {
|
||||
scanOnboardSearch(lines, relativeFile, inventory);
|
||||
continue;
|
||||
}
|
||||
|
||||
scanGenericCoreImports(lines, relativeFile, inventory);
|
||||
}
|
||||
|
||||
return inventory.toSorted(compareInventoryEntries);
|
||||
}
|
||||
|
||||
export async function readExpectedInventory() {
|
||||
return JSON.parse(await fs.readFile(baselinePath, "utf8"));
|
||||
}
|
||||
|
||||
export function diffInventory(expected, actual) {
|
||||
const expectedKeys = new Set(expected.map((entry) => JSON.stringify(entry)));
|
||||
const actualKeys = new Set(actual.map((entry) => JSON.stringify(entry)));
|
||||
const missing = expected.filter((entry) => !actualKeys.has(JSON.stringify(entry)));
|
||||
const unexpected = actual.filter((entry) => !expectedKeys.has(JSON.stringify(entry)));
|
||||
return {
|
||||
missing: missing.toSorted(compareInventoryEntries),
|
||||
unexpected: unexpected.toSorted(compareInventoryEntries),
|
||||
};
|
||||
}
|
||||
|
||||
function formatInventoryHuman(inventory) {
|
||||
if (inventory.length === 0) {
|
||||
return "No web search provider boundary inventory entries found.";
|
||||
}
|
||||
const lines = ["Web search provider boundary inventory:"];
|
||||
let activeProvider = "";
|
||||
for (const entry of inventory) {
|
||||
if (entry.provider !== activeProvider) {
|
||||
activeProvider = entry.provider;
|
||||
lines.push(`${activeProvider}:`);
|
||||
}
|
||||
lines.push(` - ${entry.file}:${entry.line} ${entry.reason}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatEntry(entry) {
|
||||
return `${entry.provider} ${entry.file}:${entry.line} ${entry.reason}`;
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const json = argv.includes("--json");
|
||||
const actual = await collectWebSearchProviderBoundaryInventory();
|
||||
const expected = await readExpectedInventory();
|
||||
const { missing, unexpected } = diffInventory(expected, actual);
|
||||
const matchesBaseline = missing.length === 0 && unexpected.length === 0;
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify(actual, null, 2)}\n`);
|
||||
} else {
|
||||
console.log(formatInventoryHuman(actual));
|
||||
console.log(
|
||||
matchesBaseline
|
||||
? `Baseline matches (${actual.length} entries).`
|
||||
: `Baseline mismatch (${unexpected.length} unexpected, ${missing.length} missing).`,
|
||||
);
|
||||
if (!matchesBaseline) {
|
||||
if (unexpected.length > 0) {
|
||||
console.error("Unexpected entries:");
|
||||
for (const entry of unexpected) {
|
||||
console.error(`- ${formatEntry(entry)}`);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
console.error("Missing baseline entries:");
|
||||
for (const entry of missing) {
|
||||
console.error(`- ${formatEntry(entry)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchesBaseline) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runAsScript(import.meta.url, main);
|
||||
File diff suppressed because it is too large
Load Diff
213
src/agents/tools/web-search-provider-common.ts
Normal file
213
src/agents/tools/web-search-provider-common.ts
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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("_");
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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,20 +21,22 @@ 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) => ({
|
||||
}).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 {
|
||||
@ -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: [
|
||||
|
||||
@ -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"),
|
||||
},
|
||||
],
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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: () => ({
|
||||
|
||||
@ -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");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const BUNDLED_WEB_SEARCH_PLUGINS: readonly RegistrablePlugin[] = [
|
||||
bravePlugin,
|
||||
firecrawlPlugin,
|
||||
googlePlugin,
|
||||
moonshotPlugin,
|
||||
perplexityPlugin,
|
||||
xaiPlugin,
|
||||
];
|
||||
function applyVitestContractMetadataCompat(
|
||||
providers: PluginWebSearchProviderEntry[],
|
||||
env?: PluginLoadOptions["env"],
|
||||
): PluginWebSearchProviderEntry[] {
|
||||
if (!(env?.VITEST || process.env.VITEST)) {
|
||||
return providers;
|
||||
}
|
||||
|
||||
const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = BUNDLED_WEB_SEARCH_PLUGINS.map(
|
||||
(plugin) => plugin.id,
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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,24 +451,25 @@ 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
for (const path of inactivePathsForProvider(provider)) {
|
||||
pushInactiveSurfaceWarning({
|
||||
context: params.context,
|
||||
path,
|
||||
@ -516,17 +477,18 @@ export async function resolveRuntimeWebTools(params: {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (searchEnabled && search && configuredProvider) {
|
||||
for (const provider of providers) {
|
||||
if (provider.id === configuredProvider) {
|
||||
continue;
|
||||
}
|
||||
const path = keyPathForProvider(provider.id);
|
||||
const value = provider.getCredentialValue(search);
|
||||
if (!hasConfiguredSecretRef(value, defaults)) {
|
||||
continue;
|
||||
}
|
||||
for (const path of inactivePathsForProvider(provider)) {
|
||||
pushInactiveSurfaceWarning({
|
||||
context: params.context,
|
||||
path,
|
||||
@ -534,6 +496,7 @@ export async function resolveRuntimeWebTools(params: {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetch = isRecord(web?.fetch) ? (web.fetch as FetchConfig) : undefined;
|
||||
const firecrawl = isRecord(fetch?.firecrawl) ? fetch.firecrawl : undefined;
|
||||
|
||||
538
test/fixtures/plugin-extension-import-boundary-inventory.json
vendored
Normal file
538
test/fixtures/plugin-extension-import-boundary-inventory.json
vendored
Normal file
@ -0,0 +1,538 @@
|
||||
[
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 1,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/anthropic/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/anthropic/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 2,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/byteplus/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/byteplus/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 3,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/cloudflare-ai-gateway/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/cloudflare-ai-gateway/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 4,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/copilot-proxy/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/copilot-proxy/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 5,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/github-copilot/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/github-copilot/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 6,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/google/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/google/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 7,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/huggingface/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/huggingface/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 8,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/kilocode/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/kilocode/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 9,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/kimi-coding/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/kimi-coding/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 10,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/minimax/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/minimax/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 11,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/mistral/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/mistral/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 12,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/modelstudio/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/modelstudio/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 13,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/moonshot/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/moonshot/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 14,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/nvidia/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/nvidia/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 15,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/ollama/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/ollama/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 16,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/openai/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/openai/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 17,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/opencode-go/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/opencode-go/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 18,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/opencode/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/opencode/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 19,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/openrouter/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/openrouter/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 20,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/qianfan/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/qianfan/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 21,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/qwen-portal-auth/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/qwen-portal-auth/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 22,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/sglang/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/sglang/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 23,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/synthetic/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/synthetic/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 24,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/together/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/together/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 25,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/venice/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/venice/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 26,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/vercel-ai-gateway/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/vercel-ai-gateway/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 27,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/vllm/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/vllm/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 28,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/volcengine/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/volcengine/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 29,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/xai/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/xai/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 30,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/xiaomi/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/xiaomi/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/bundled-provider-auth-env-vars.ts",
|
||||
"line": 31,
|
||||
"kind": "import",
|
||||
"specifier": "../../extensions/zai/openclaw.plugin.json",
|
||||
"resolvedPath": "extensions/zai/openclaw.plugin.json",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-discord-ops.runtime.ts",
|
||||
"line": 1,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/discord/src/audit.js",
|
||||
"resolvedPath": "extensions/discord/src/audit.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-discord-ops.runtime.ts",
|
||||
"line": 5,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/discord/src/directory-live.js",
|
||||
"resolvedPath": "extensions/discord/src/directory-live.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-discord-ops.runtime.ts",
|
||||
"line": 6,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/discord/src/monitor.js",
|
||||
"resolvedPath": "extensions/discord/src/monitor.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-discord-ops.runtime.ts",
|
||||
"line": 7,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/discord/src/probe.js",
|
||||
"resolvedPath": "extensions/discord/src/probe.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-discord-ops.runtime.ts",
|
||||
"line": 8,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/discord/src/resolve-channels.js",
|
||||
"resolvedPath": "extensions/discord/src/resolve-channels.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-discord-ops.runtime.ts",
|
||||
"line": 9,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/discord/src/resolve-users.js",
|
||||
"resolvedPath": "extensions/discord/src/resolve-users.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-discord-ops.runtime.ts",
|
||||
"line": 21,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/discord/src/send.js",
|
||||
"resolvedPath": "extensions/discord/src/send.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-discord.ts",
|
||||
"line": 1,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/discord/src/channel-actions.js",
|
||||
"resolvedPath": "extensions/discord/src/channel-actions.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-discord.ts",
|
||||
"line": 11,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/discord/src/monitor/thread-bindings.js",
|
||||
"resolvedPath": "extensions/discord/src/monitor/thread-bindings.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-imessage.ts",
|
||||
"line": 1,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/imessage/src/monitor.js",
|
||||
"resolvedPath": "extensions/imessage/src/monitor.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-imessage.ts",
|
||||
"line": 2,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/imessage/src/probe.js",
|
||||
"resolvedPath": "extensions/imessage/src/probe.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-imessage.ts",
|
||||
"line": 3,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/imessage/src/send.js",
|
||||
"resolvedPath": "extensions/imessage/src/send.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-media.ts",
|
||||
"line": 1,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/whatsapp/src/media.js",
|
||||
"resolvedPath": "extensions/whatsapp/src/media.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-signal.ts",
|
||||
"line": 1,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/signal/src/index.js",
|
||||
"resolvedPath": "extensions/signal/src/index.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-signal.ts",
|
||||
"line": 2,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/signal/src/probe.js",
|
||||
"resolvedPath": "extensions/signal/src/probe.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-signal.ts",
|
||||
"line": 3,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/signal/src/send.js",
|
||||
"resolvedPath": "extensions/signal/src/send.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-slack-ops.runtime.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/slack/src/directory-live.js",
|
||||
"resolvedPath": "extensions/slack/src/directory-live.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-slack-ops.runtime.ts",
|
||||
"line": 5,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/slack/src/index.js",
|
||||
"resolvedPath": "extensions/slack/src/index.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-slack-ops.runtime.ts",
|
||||
"line": 6,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/slack/src/probe.js",
|
||||
"resolvedPath": "extensions/slack/src/probe.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-slack-ops.runtime.ts",
|
||||
"line": 7,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/slack/src/resolve-channels.js",
|
||||
"resolvedPath": "extensions/slack/src/resolve-channels.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-slack-ops.runtime.ts",
|
||||
"line": 8,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/slack/src/resolve-users.js",
|
||||
"resolvedPath": "extensions/slack/src/resolve-users.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-slack-ops.runtime.ts",
|
||||
"line": 9,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/slack/src/send.js",
|
||||
"resolvedPath": "extensions/slack/src/send.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/telegram/src/audit.js",
|
||||
"resolvedPath": "extensions/telegram/src/audit.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts",
|
||||
"line": 5,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/telegram/src/monitor.js",
|
||||
"resolvedPath": "extensions/telegram/src/monitor.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts",
|
||||
"line": 6,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/telegram/src/probe.js",
|
||||
"resolvedPath": "extensions/telegram/src/probe.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts",
|
||||
"line": 17,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/telegram/src/send.js",
|
||||
"resolvedPath": "extensions/telegram/src/send.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts",
|
||||
"line": 18,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/telegram/src/token.js",
|
||||
"resolvedPath": "extensions/telegram/src/token.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-telegram.ts",
|
||||
"line": 1,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/telegram/src/audit.js",
|
||||
"resolvedPath": "extensions/telegram/src/audit.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-telegram.ts",
|
||||
"line": 2,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/telegram/src/channel-actions.js",
|
||||
"resolvedPath": "extensions/telegram/src/channel-actions.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-telegram.ts",
|
||||
"line": 6,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/telegram/src/thread-bindings.js",
|
||||
"resolvedPath": "extensions/telegram/src/thread-bindings.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-telegram.ts",
|
||||
"line": 7,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/telegram/src/token.js",
|
||||
"resolvedPath": "extensions/telegram/src/token.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-whatsapp-login.runtime.ts",
|
||||
"line": 1,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/whatsapp/src/login.js",
|
||||
"resolvedPath": "extensions/whatsapp/src/login.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts",
|
||||
"line": 1,
|
||||
"kind": "export",
|
||||
"specifier": "../../../extensions/whatsapp/src/send.js",
|
||||
"resolvedPath": "extensions/whatsapp/src/send.js",
|
||||
"reason": "re-exports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-whatsapp.ts",
|
||||
"line": 1,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/whatsapp/src/active-listener.js",
|
||||
"resolvedPath": "extensions/whatsapp/src/active-listener.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-whatsapp.ts",
|
||||
"line": 8,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/whatsapp/src/auth-store.js",
|
||||
"resolvedPath": "extensions/whatsapp/src/auth-store.js",
|
||||
"reason": "imports extension implementation from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-whatsapp.ts",
|
||||
"line": 80,
|
||||
"kind": "dynamic-import",
|
||||
"specifier": "../../../extensions/whatsapp/src/login-qr.js",
|
||||
"resolvedPath": "extensions/whatsapp/src/login-qr.js",
|
||||
"reason": "dynamically imports extension implementation from src/plugins"
|
||||
}
|
||||
]
|
||||
32
test/fixtures/web-search-provider-boundary-inventory.json
vendored
Normal file
32
test/fixtures/web-search-provider-boundary-inventory.json
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"provider": "shared",
|
||||
"file": "src/commands/onboard-search.ts",
|
||||
"line": 9,
|
||||
"reason": "imports bundled web search registry into core onboarding flow"
|
||||
},
|
||||
{
|
||||
"provider": "shared",
|
||||
"file": "src/config/config.web-search-provider.test.ts",
|
||||
"line": 9,
|
||||
"reason": "imports bundled web search registry outside allowed generic plumbing"
|
||||
},
|
||||
{
|
||||
"provider": "shared",
|
||||
"file": "src/plugins/contracts/loader.contract.test.ts",
|
||||
"line": 11,
|
||||
"reason": "imports bundled web search registry outside allowed generic plumbing"
|
||||
},
|
||||
{
|
||||
"provider": "shared",
|
||||
"file": "src/plugins/web-search-providers.test.ts",
|
||||
"line": 7,
|
||||
"reason": "imports bundled web search registry outside allowed generic plumbing"
|
||||
},
|
||||
{
|
||||
"provider": "shared",
|
||||
"file": "src/secrets/runtime-web-tools.test.ts",
|
||||
"line": 3,
|
||||
"reason": "imports bundled web search registry outside allowed generic plumbing"
|
||||
}
|
||||
]
|
||||
79
test/plugin-extension-import-boundary.test.ts
Normal file
79
test/plugin-extension-import-boundary.test.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectPluginExtensionImportBoundaryInventory,
|
||||
diffInventory,
|
||||
} from "../scripts/check-plugin-extension-import-boundary.mjs";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const scriptPath = path.join(repoRoot, "scripts", "check-plugin-extension-import-boundary.mjs");
|
||||
const baselinePath = path.join(
|
||||
repoRoot,
|
||||
"test",
|
||||
"fixtures",
|
||||
"plugin-extension-import-boundary-inventory.json",
|
||||
);
|
||||
|
||||
function readBaseline() {
|
||||
return JSON.parse(readFileSync(baselinePath, "utf8"));
|
||||
}
|
||||
|
||||
describe("plugin extension import boundary inventory", () => {
|
||||
it("keeps web-search-providers out of the remaining inventory", async () => {
|
||||
const inventory = await collectPluginExtensionImportBoundaryInventory();
|
||||
|
||||
expect(inventory.some((entry) => entry.file === "src/plugins/web-search-providers.ts")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(inventory).toContainEqual(
|
||||
expect.objectContaining({
|
||||
file: "src/plugins/runtime/runtime-signal.ts",
|
||||
resolvedPath: "extensions/signal/src/index.js",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores plugin-sdk boundary shims by scope", async () => {
|
||||
const inventory = await collectPluginExtensionImportBoundaryInventory();
|
||||
|
||||
expect(inventory.some((entry) => entry.file.startsWith("src/plugin-sdk/"))).toBe(false);
|
||||
expect(inventory.some((entry) => entry.file.startsWith("src/plugin-sdk-internal/"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("produces stable sorted output", async () => {
|
||||
const first = await collectPluginExtensionImportBoundaryInventory();
|
||||
const second = await collectPluginExtensionImportBoundaryInventory();
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(
|
||||
[...first].toSorted(
|
||||
(left, right) =>
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.kind.localeCompare(right.kind) ||
|
||||
left.specifier.localeCompare(right.specifier) ||
|
||||
left.reason.localeCompare(right.reason),
|
||||
),
|
||||
).toEqual(first);
|
||||
});
|
||||
|
||||
it("matches the checked-in baseline", async () => {
|
||||
const expected = readBaseline();
|
||||
const actual = await collectPluginExtensionImportBoundaryInventory();
|
||||
|
||||
expect(diffInventory(expected, actual)).toEqual({ missing: [], unexpected: [] });
|
||||
});
|
||||
|
||||
it("script json output matches the baseline exactly", () => {
|
||||
const stdout = execFileSync(process.execPath, [scriptPath, "--json"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(JSON.parse(stdout)).toEqual(readBaseline());
|
||||
});
|
||||
});
|
||||
72
test/web-search-provider-boundary.test.ts
Normal file
72
test/web-search-provider-boundary.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectWebSearchProviderBoundaryInventory,
|
||||
diffInventory,
|
||||
} from "../scripts/check-web-search-provider-boundaries.mjs";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const scriptPath = path.join(repoRoot, "scripts", "check-web-search-provider-boundaries.mjs");
|
||||
const baselinePath = path.join(
|
||||
repoRoot,
|
||||
"test",
|
||||
"fixtures",
|
||||
"web-search-provider-boundary-inventory.json",
|
||||
);
|
||||
|
||||
function readBaseline() {
|
||||
return JSON.parse(readFileSync(baselinePath, "utf8"));
|
||||
}
|
||||
|
||||
describe("web search provider boundary inventory", () => {
|
||||
it("finds the current shared core onboarding import", async () => {
|
||||
const inventory = await collectWebSearchProviderBoundaryInventory();
|
||||
|
||||
expect(inventory).toContainEqual(
|
||||
expect.objectContaining({
|
||||
provider: "shared",
|
||||
file: "src/commands/onboard-search.ts",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores extension-owned registrations", async () => {
|
||||
const inventory = await collectWebSearchProviderBoundaryInventory();
|
||||
|
||||
expect(inventory.some((entry) => entry.file.startsWith("extensions/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("produces stable sorted output", async () => {
|
||||
const first = await collectWebSearchProviderBoundaryInventory();
|
||||
const second = await collectWebSearchProviderBoundaryInventory();
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(
|
||||
[...first].toSorted(
|
||||
(left, right) =>
|
||||
left.provider.localeCompare(right.provider) ||
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.reason.localeCompare(right.reason),
|
||||
),
|
||||
).toEqual(first);
|
||||
});
|
||||
|
||||
it("matches the checked-in baseline", async () => {
|
||||
const expected = readBaseline();
|
||||
const actual = await collectWebSearchProviderBoundaryInventory();
|
||||
|
||||
expect(diffInventory(expected, actual)).toEqual({ missing: [], unexpected: [] });
|
||||
});
|
||||
|
||||
it("script json output matches the baseline exactly", () => {
|
||||
const stdout = execFileSync(process.execPath, [scriptPath, "--json"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(JSON.parse(stdout)).toEqual(readBaseline());
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user