test: narrow web search contract runtime loads

Honor targeted includes in the contracts Vitest lane and compare bundled
web-search fast-path artifacts against plugin-owned runtime artifacts instead
of loading whole plugin entries. Split Google and Firecrawl runtime-only work
behind lazy seams so provider registration stays metadata-light.

Also keep Perplexity contract metadata aligned by sharing its runtime transport
resolution with the contract artifact.
This commit is contained in:
Gustavo Madeira Santana
2026-04-17 17:07:56 -04:00
parent c03f97f954
commit 2482e70fb8
12 changed files with 482 additions and 362 deletions

View File

@@ -1,27 +1,22 @@
import { Type } from "@sinclair/typebox";
import {
enablePluginInConfig,
getScopedCredentialValue,
resolveProviderWebSearchPluginConfig,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
import { runFirecrawlSearch } from "./firecrawl-client.js";
} from "openclaw/plugin-sdk/provider-web-search-contract";
const GenericFirecrawlSearchSchema = 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: 10,
}),
),
const FIRECRAWL_CREDENTIAL_PATH = "plugins.entries.firecrawl.config.webSearch.apiKey";
const GenericFirecrawlSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
},
{ additionalProperties: false },
);
additionalProperties: false,
} satisfies Record<string, unknown>;
export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
return {
@@ -35,27 +30,25 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://www.firecrawl.dev/",
docsUrl: "https://docs.openclaw.ai/tools/firecrawl",
autoDetectOrder: 60,
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "firecrawl"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "firecrawl", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "firecrawl")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "firecrawl", "apiKey", value);
},
applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config,
credentialPath: FIRECRAWL_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: FIRECRAWL_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "firecrawl" },
configuredCredential: { pluginId: "firecrawl" },
selectionPluginId: "firecrawl",
}),
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.",
parameters: GenericFirecrawlSearchSchema,
execute: async (args) =>
await runFirecrawlSearch({
execute: async (args) => {
const { runFirecrawlSearch } = await import("./firecrawl-client.js");
return await runFirecrawlSearch({
cfg: ctx.config,
query: typeof args.query === "string" ? args.query : "",
count: typeof args.count === "number" ? args.count : undefined,
}),
});
},
}),
};
}

View File

@@ -0,0 +1,194 @@
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveCitationRedirectUrl,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
type SearchConfigRecord,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { DEFAULT_GOOGLE_API_BASE_URL } from "../api.js";
import {
resolveGeminiConfig,
resolveGeminiModel,
type GeminiConfig,
} from "./gemini-web-search-provider.shared.js";
const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL;
type GeminiGroundingResponse = {
candidates?: Array<{
content?: {
parts?: Array<{
text?: string;
}>;
};
groundingMetadata?: {
groundingChunks?: Array<{
web?: {
uri?: string;
title?: string;
};
}>;
};
}>;
error?: {
code?: number;
message?: string;
status?: string;
};
};
export function resolveGeminiRuntimeApiKey(gemini?: GeminiConfig): string | undefined {
return (
readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ??
readProviderEnvValue(["GEMINI_API_KEY"])
);
}
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]+/giu,
"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]+/giu, "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]+/giu, "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 };
},
);
}
export async function executeGeminiSearch(
args: Record<string, unknown>,
searchConfig?: SearchConfigRecord,
): Promise<Record<string, unknown>> {
const unsupportedResponse = buildUnsupportedSearchFilterResponse(args, "gemini");
if (unsupportedResponse) {
return unsupportedResponse;
}
const geminiConfig = resolveGeminiConfig(searchConfig);
const apiKey = resolveGeminiRuntimeApiKey(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(args, "query", { required: true });
const count =
readNumberParam(args, "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;
}

View File

@@ -0,0 +1,30 @@
export const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash";
export type GeminiConfig = {
apiKey?: unknown;
model?: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function trimToUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function resolveGeminiConfig(searchConfig?: Record<string, unknown>): GeminiConfig {
const gemini = searchConfig?.gemini;
return isRecord(gemini) ? gemini : {};
}
export function resolveGeminiApiKey(
gemini?: GeminiConfig,
env: Record<string, string | undefined> = process.env,
): string | undefined {
return trimToUndefined(gemini?.apiKey) ?? trimToUndefined(env.GEMINI_API_KEY);
}
export function resolveGeminiModel(gemini?: GeminiConfig): string {
return trimToUndefined(gemini?.model) ?? DEFAULT_GEMINI_WEB_SEARCH_MODEL;
}

View File

@@ -1,244 +1,42 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
getScopedCredentialValue,
MAX_SEARCH_COUNT,
createWebSearchProviderContractFields,
mergeScopedSearchConfig,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveCitationRedirectUrl,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
type SearchConfigRecord,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { DEFAULT_GOOGLE_API_BASE_URL } from "../api.js";
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { resolveGeminiApiKey, resolveGeminiModel } from "./gemini-web-search-provider.shared.js";
const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL;
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 = normalizeOptionalString(gemini?.model) ?? "";
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: {} }],
}),
},
const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey";
const GEMINI_TOOL_PARAMETERS = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
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." })),
});
}
country: { type: "string", description: "Not supported by Gemini." },
language: { type: "string", description: "Not supported by Gemini." },
freshness: { type: "string", description: "Not supported by Gemini." },
date_after: { type: "string", description: "Not supported by Gemini." },
date_before: { type: "string", description: "Not supported by Gemini." },
},
required: ["query"],
} satisfies Record<string, unknown>;
function createGeminiToolDefinition(
searchConfig?: SearchConfigRecord,
searchConfig?: Record<string, unknown>,
): WebSearchProviderToolDefinition {
return {
description:
"Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.",
parameters: createGeminiSchema(),
parameters: GEMINI_TOOL_PARAMETERS,
execute: async (args) => {
const params = args;
const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "gemini");
if (unsupportedResponse) {
return unsupportedResponse;
}
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;
const { executeGeminiSearch } = await import("./gemini-web-search-provider.runtime.js");
return await executeGeminiSearch(args, searchConfig);
},
};
}
@@ -255,23 +53,19 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://aistudio.google.com/apikey",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 20,
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "gemini", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "google")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "google", "apiKey", value);
},
credentialPath: GEMINI_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: GEMINI_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "gemini" },
configuredCredential: { pluginId: "google" },
}),
createTool: (ctx) =>
createGeminiToolDefinition(
mergeScopedSearchConfig(
ctx.searchConfig as SearchConfigRecord | undefined,
ctx.searchConfig,
"gemini",
resolveProviderWebSearchPluginConfig(ctx.config, "google"),
) as SearchConfigRecord | undefined,
),
),
};
}

View File

@@ -0,0 +1,82 @@
export const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
export const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
export type PerplexityTransport = "search_api" | "chat_completions";
export type PerplexityBaseUrlHint = "direct" | "openrouter";
export type PerplexityRuntimeTransportContext = {
searchConfig?: Record<string, unknown>;
resolvedKey?: string;
keySource: "config" | "secretRef" | "env" | "missing";
fallbackEnvVar?: string;
};
function trimToUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return trimToUndefined(value)?.toLowerCase() ?? "";
}
export function inferPerplexityBaseUrlFromApiKey(
apiKey?: string,
): PerplexityBaseUrlHint | undefined {
if (!apiKey) {
return undefined;
}
const normalized = normalizeLowercaseStringOrEmpty(apiKey);
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "direct";
}
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "openrouter";
}
return undefined;
}
export function isDirectPerplexityBaseUrl(baseUrl: string): boolean {
try {
return (
normalizeLowercaseStringOrEmpty(new URL(baseUrl.trim()).hostname) === "api.perplexity.ai"
);
} catch {
return false;
}
}
export function resolvePerplexityRuntimeTransport(
params: PerplexityRuntimeTransportContext,
): 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 = trimToUndefined(scoped?.baseUrl) ?? "";
const configuredModel = trimToUndefined(scoped?.model) ?? "";
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";
}

View File

@@ -25,24 +25,24 @@ import {
setProviderWebSearchPluginConfigValue,
throwWebSearchApiError,
type SearchConfigRecord,
type WebSearchCredentialResolutionSource,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
DEFAULT_PERPLEXITY_BASE_URL,
inferPerplexityBaseUrlFromApiKey,
isDirectPerplexityBaseUrl,
PERPLEXITY_DIRECT_BASE_URL,
resolvePerplexityRuntimeTransport,
type PerplexityTransport,
} from "./perplexity-web-search-provider.shared.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;
@@ -50,9 +50,6 @@ type PerplexityConfig = {
model?: string;
};
type PerplexityTransport = "search_api" | "chat_completions";
type PerplexityBaseUrlHint = "direct" | "openrouter";
type PerplexitySearchResponse = {
choices?: Array<{
message?: {
@@ -85,20 +82,6 @@ function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityC
: {};
}
function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined {
if (!apiKey) {
return undefined;
}
const normalized = normalizeLowercaseStringOrEmpty(apiKey);
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";
@@ -149,16 +132,6 @@ function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
return model || DEFAULT_PERPLEXITY_MODEL;
}
function isDirectPerplexityBaseUrl(baseUrl: string): boolean {
try {
return (
normalizeLowercaseStringOrEmpty(new URL(baseUrl.trim()).hostname) === "api.perplexity.ai"
);
} catch {
return false;
}
}
function resolvePerplexityRequestModel(baseUrl: string, model: string): string {
if (!isDirectPerplexityBaseUrl(baseUrl)) {
return model;
@@ -336,43 +309,6 @@ async function runPerplexitySearch(params: {
);
}
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 = normalizeOptionalString(scoped?.baseUrl) ?? "";
const configuredModel = normalizeOptionalString(scoped?.model) ?? "";
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." }),
@@ -697,7 +633,7 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
setProviderWebSearchPluginConfigValue(configTarget, "perplexity", "apiKey", value);
},
resolveRuntimeMetadata: (ctx) => ({
perplexityTransport: resolveRuntimeTransport({
perplexityTransport: resolvePerplexityRuntimeTransport({
searchConfig: mergeScopedSearchConfig(
ctx.searchConfig,
"perplexity",

View File

@@ -1,7 +1,10 @@
import {
createWebSearchProviderContractFields,
mergeScopedSearchConfig,
resolveProviderWebSearchPluginConfig,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { resolvePerplexityRuntimeTransport } from "./src/perplexity-web-search-provider.shared.js";
export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
const credentialPath = "plugins.entries.perplexity.config.webSearch.apiKey";
@@ -23,6 +26,18 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
searchCredential: { type: "scoped", scopeId: "perplexity" },
configuredCredential: { pluginId: "perplexity" },
}),
resolveRuntimeMetadata: (ctx) => ({
perplexityTransport: resolvePerplexityRuntimeTransport({
searchConfig: mergeScopedSearchConfig(
ctx.searchConfig,
"perplexity",
resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"),
),
resolvedKey: ctx.resolvedCredential?.value,
keySource: ctx.resolvedCredential?.source ?? "missing",
fallbackEnvVar: ctx.resolvedCredential?.fallbackEnvVar,
}),
}),
createTool: () => null,
};
}

View File

@@ -14,6 +14,7 @@ vi.mock("./manifest-registry.js", async (importOriginal) => {
};
});
import { resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts as resolveExplicitRuntimeWebSearchProviders } from "./web-provider-public-artifacts.explicit.js";
import {
resolveBundledWebFetchProvidersFromPublicArtifacts,
resolveBundledWebSearchProvidersFromPublicArtifacts,
@@ -35,6 +36,16 @@ describe("web provider public artifacts explicit fast path", () => {
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled();
});
it("resolves bundled runtime web search providers by explicit plugin id", () => {
const provider = resolveExplicitRuntimeWebSearchProviders({
onlyPluginIds: ["google"],
})?.[0];
expect(provider?.pluginId).toBe("google");
expect(provider?.createTool({ config: {} as never })).not.toBeNull();
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled();
});
it("resolves bundled web fetch providers by explicit plugin id without manifest scans", () => {
const provider = resolveBundledWebFetchProvidersFromPublicArtifacts({
bundledAllowlistCompat: true,

View File

@@ -14,6 +14,7 @@ const WEB_SEARCH_ARTIFACT_CANDIDATES = [
"web-search-provider.js",
"web-search.js",
] as const;
const WEB_SEARCH_RUNTIME_ARTIFACT_CANDIDATES = ["web-search-provider.js", "web-search.js"] as const;
const WEB_FETCH_ARTIFACT_CANDIDATES = [
"web-fetch-contract-api.js",
"web-fetch-provider.js",
@@ -128,6 +129,28 @@ export function loadBundledWebSearchProviderEntriesFromDir(params: {
return providers.map((provider) => ({ ...provider, pluginId: params.pluginId }));
}
export function loadBundledRuntimeWebSearchProviderEntriesFromDir(params: {
dirName: string;
pluginId: string;
}): PluginWebSearchProviderEntry[] | null {
const mod = tryLoadBundledPublicArtifactModule({
dirName: params.dirName,
artifactCandidates: WEB_SEARCH_RUNTIME_ARTIFACT_CANDIDATES,
});
if (!mod) {
return null;
}
const providers = collectProviderFactories({
mod,
suffix: "WebSearchProvider",
isProvider: isWebSearchProviderPlugin,
});
if (providers.length === 0) {
return null;
}
return providers.map((provider) => ({ ...provider, pluginId: params.pluginId }));
}
export function loadBundledWebFetchProviderEntriesFromDir(params: {
dirName: string;
pluginId: string;
@@ -167,6 +190,23 @@ export function resolveBundledExplicitWebSearchProvidersFromPublicArtifacts(para
return providers;
}
export function resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts(params: {
onlyPluginIds: readonly string[];
}): PluginWebSearchProviderEntry[] | null {
const providers: PluginWebSearchProviderEntry[] = [];
for (const pluginId of normalizeExplicitBundledPluginIds(params.onlyPluginIds)) {
const loadedProviders = loadBundledRuntimeWebSearchProviderEntriesFromDir({
dirName: pluginId,
pluginId,
});
if (!loadedProviders) {
return null;
}
providers.push(...loadedProviders);
}
return providers;
}
export function resolveBundledExplicitWebFetchProvidersFromPublicArtifacts(params: {
onlyPluginIds: readonly string[];
}): PluginWebFetchProviderEntry[] | null {

View File

@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resolveManifestContractOwnerPluginId } from "../../../src/plugins/manifest-registry.js";
import { resolveBundledExplicitWebSearchProvidersFromPublicArtifacts } from "../../../src/plugins/web-provider-public-artifacts.explicit.js";
import { resolvePluginWebSearchProviders } from "../../../src/plugins/web-search-providers.runtime.js";
import {
resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts,
resolveBundledExplicitWebSearchProvidersFromPublicArtifacts,
} from "../../../src/plugins/web-provider-public-artifacts.explicit.js";
type ComparableProvider = {
pluginId: string;
@@ -94,19 +96,16 @@ export function describeBundledWebSearchFastPathContract(pluginId: string) {
}
});
it("keeps fast-path provider metadata aligned with bundled public artifacts", async () => {
const fastPathProviders = resolvePluginWebSearchProviders({
origin: "bundled",
onlyPluginIds: [pluginId],
mode: "setup",
}).filter((provider) => provider.pluginId === pluginId);
const bundledProviderEntries =
it("keeps fast-path provider metadata aligned with the bundled runtime artifact", async () => {
const fastPathProviders =
resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({
onlyPluginIds: [pluginId],
})?.filter((provider) => provider.pluginId === pluginId) ?? [];
const bundledProviderEntries =
resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts({
onlyPluginIds: [pluginId],
})?.filter((entry) => entry.pluginId === pluginId) ?? [];
expect(bundledProviderEntries.length).toBeGreaterThan(0);
expect(
sortComparableEntries(
fastPathProviders.map((provider) =>

View File

@@ -41,6 +41,19 @@ describe("projects vitest config", () => {
expect(normalizeConfigPath(config.test.runner)).toBe("test/non-isolated-runner.ts");
});
it("narrows the contracts lane to targeted contract files", () => {
const config = createContractsVitestConfig({}, [
"node",
"vitest",
"run",
"src/plugins/contracts/bundled-web-search.google.contract.test.ts",
]);
expect(config.test.include).toEqual([
"src/plugins/contracts/bundled-web-search.google.contract.test.ts",
]);
});
it("keeps the root ui lane aligned with the isolated jsdom setup", () => {
const config = createUiVitestConfig();
expect(config.test.environment).toBe("jsdom");

View File

@@ -1,10 +1,25 @@
import { defineConfig } from "vitest/config";
import { loadPatternListFromEnv, narrowIncludePatternsForCli } from "./vitest.pattern-file.ts";
import { nonIsolatedRunnerPath, sharedVitestConfig } from "./vitest.shared.config.ts";
const base = sharedVitestConfig as Record<string, unknown>;
const baseTest = sharedVitestConfig.test ?? {};
const contractIncludePatterns = [
"src/channels/plugins/contracts/**/*.test.ts",
"src/plugins/contracts/**/*.test.ts",
];
export function createContractsVitestConfig() {
export function loadContractsIncludePatternsFromEnv(
env: Record<string, string | undefined> = process.env,
): string[] | null {
return loadPatternListFromEnv("OPENCLAW_VITEST_INCLUDE_FILE", env);
}
export function createContractsVitestConfig(
env: Record<string, string | undefined> = process.env,
argv: string[] = process.argv,
) {
const cliIncludePatterns = narrowIncludePatternsForCli(contractIncludePatterns, argv);
return defineConfig({
...base,
test: {
@@ -16,10 +31,8 @@ export function createContractsVitestConfig() {
pool: "forks",
runner: nonIsolatedRunnerPath,
setupFiles: baseTest.setupFiles ?? [],
include: [
"src/channels/plugins/contracts/**/*.test.ts",
"src/plugins/contracts/**/*.test.ts",
],
include:
loadContractsIncludePatternsFromEnv(env) ?? cliIncludePatterns ?? contractIncludePatterns,
passWithNoTests: true,
},
});