diff --git a/extensions/firecrawl/src/firecrawl-search-provider.ts b/extensions/firecrawl/src/firecrawl-search-provider.ts index 6ba5cefa4ed..3f93fa2c738 100644 --- a/extensions/firecrawl/src/firecrawl-search-provider.ts +++ b/extensions/firecrawl/src/firecrawl-search-provider.ts @@ -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; 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, - }), + }); + }, }), }; } diff --git a/extensions/google/src/gemini-web-search-provider.runtime.ts b/extensions/google/src/gemini-web-search-provider.runtime.ts new file mode 100644 index 00000000000..2f3252930ec --- /dev/null +++ b/extensions/google/src/gemini-web-search-provider.runtime.ts @@ -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, + searchConfig?: SearchConfigRecord, +): Promise> { + 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; +} diff --git a/extensions/google/src/gemini-web-search-provider.shared.ts b/extensions/google/src/gemini-web-search-provider.shared.ts new file mode 100644 index 00000000000..dd754ca7479 --- /dev/null +++ b/extensions/google/src/gemini-web-search-provider.shared.ts @@ -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 { + 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): GeminiConfig { + const gemini = searchConfig?.gemini; + return isRecord(gemini) ? gemini : {}; +} + +export function resolveGeminiApiKey( + gemini?: GeminiConfig, + env: Record = 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; +} diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index c10e6ba437a..37ca0966d8e 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -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; function createGeminiToolDefinition( - searchConfig?: SearchConfigRecord, + searchConfig?: Record, ): 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, + ), ), }; } diff --git a/extensions/perplexity/src/perplexity-web-search-provider.shared.ts b/extensions/perplexity/src/perplexity-web-search-provider.shared.ts new file mode 100644 index 00000000000..016b0979362 --- /dev/null +++ b/extensions/perplexity/src/perplexity-web-search-provider.shared.ts @@ -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; + 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"; +} diff --git a/extensions/perplexity/src/perplexity-web-search-provider.ts b/extensions/perplexity/src/perplexity-web-search-provider.ts index ad9748ce5b2..7faa62597c2 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.ts @@ -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; - 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", diff --git a/extensions/perplexity/web-search-contract-api.ts b/extensions/perplexity/web-search-contract-api.ts index 60bf42e2288..a5cdeb901a8 100644 --- a/extensions/perplexity/web-search-contract-api.ts +++ b/extensions/perplexity/web-search-contract-api.ts @@ -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, }; } diff --git a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts index 2e02de9c405..459daefdbec 100644 --- a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts +++ b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts @@ -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, diff --git a/src/plugins/web-provider-public-artifacts.explicit.ts b/src/plugins/web-provider-public-artifacts.explicit.ts index 60550892da1..c801b87b5d7 100644 --- a/src/plugins/web-provider-public-artifacts.explicit.ts +++ b/src/plugins/web-provider-public-artifacts.explicit.ts @@ -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 { diff --git a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts index c7fc787622f..3b0b8d5f3bc 100644 --- a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts +++ b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts @@ -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) => diff --git a/test/vitest-projects-config.test.ts b/test/vitest-projects-config.test.ts index 9186350b702..93b6dd0a985 100644 --- a/test/vitest-projects-config.test.ts +++ b/test/vitest-projects-config.test.ts @@ -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"); diff --git a/test/vitest/vitest.contracts.config.ts b/test/vitest/vitest.contracts.config.ts index eacb6ce8981..adc768e7696 100644 --- a/test/vitest/vitest.contracts.config.ts +++ b/test/vitest/vitest.contracts.config.ts @@ -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; 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 = process.env, +): string[] | null { + return loadPatternListFromEnv("OPENCLAW_VITEST_INCLUDE_FILE", env); +} + +export function createContractsVitestConfig( + env: Record = 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, }, });