diff --git a/extensions/xai/src/web-search-provider.runtime.ts b/extensions/xai/src/web-search-provider.runtime.ts new file mode 100644 index 00000000000..3be666d2bc8 --- /dev/null +++ b/extensions/xai/src/web-search-provider.runtime.ts @@ -0,0 +1,216 @@ +import { + DEFAULT_CACHE_TTL_MINUTES, + DEFAULT_TIMEOUT_SECONDS, + formatCliCommand, + getScopedCredentialValue, + mergeScopedSearchConfig, + normalizeCacheKey, + readCache, + readNumberParam, + readStringParam, + resolveCacheTtlMs, + resolveProviderWebSearchPluginConfig, + resolveTimeoutSeconds, + resolveWebSearchProviderCredential, + type WebSearchProviderSetupContext, + writeCache, +} from "openclaw/plugin-sdk/provider-web-search"; +import { + buildXaiWebSearchPayload, + extractXaiWebSearchContent, + requestXaiWebSearch, + resolveXaiInlineCitations, + resolveXaiWebSearchModel, +} from "./web-search-shared.js"; +import { resolveEffectiveXSearchConfig, setPluginXSearchConfigValue } from "./x-search-config.js"; +import { XAI_DEFAULT_X_SEARCH_MODEL } from "./x-search-shared.js"; + +const XAI_WEB_SEARCH_CACHE = new Map< + string, + { value: Record; insertedAt: number; expiresAt: number } +>(); + +const X_SEARCH_MODEL_OPTIONS = [ + { + value: XAI_DEFAULT_X_SEARCH_MODEL, + label: XAI_DEFAULT_X_SEARCH_MODEL, + hint: "default · fast, no reasoning", + }, + { + value: "grok-4-1-fast", + label: "grok-4-1-fast", + hint: "fast with reasoning", + }, +] as const; + +function resolveXSearchConfigRecord( + config?: WebSearchProviderSetupContext["config"], +): Record | undefined { + return resolveEffectiveXSearchConfig(config); +} + +export async function runXaiSearchProviderSetup( + ctx: WebSearchProviderSetupContext, +): Promise { + const existingXSearch = resolveXSearchConfigRecord(ctx.config); + if (existingXSearch?.enabled === false) { + return ctx.config; + } + + await ctx.prompter.note( + [ + "x_search lets your agent search X (formerly Twitter) posts via xAI.", + "It reuses the same xAI API key you just configured for Grok web search.", + `You can change this later with ${formatCliCommand("openclaw configure --section web")}.`, + ].join("\n"), + "X search", + ); + + const enableChoice = await ctx.prompter.select<"yes" | "skip">({ + message: "Enable x_search too?", + options: [ + { + value: "yes", + label: "Yes, enable x_search", + hint: "Search X posts with the same xAI key", + }, + { + value: "skip", + label: "Skip for now", + hint: "Keep Grok web_search only", + }, + ], + initialValue: existingXSearch?.enabled === true || ctx.quickstartDefaults ? "yes" : "skip", + }); + + if (enableChoice === "skip") { + return ctx.config; + } + + const existingModel = + typeof existingXSearch?.model === "string" && existingXSearch.model.trim() + ? existingXSearch.model.trim() + : ""; + const knownModel = X_SEARCH_MODEL_OPTIONS.find((entry) => entry.value === existingModel)?.value; + const modelPick = await ctx.prompter.select({ + message: "Grok model for x_search", + options: [ + ...X_SEARCH_MODEL_OPTIONS, + { value: "__custom__", label: "Enter custom model name", hint: "" }, + ], + initialValue: knownModel ?? XAI_DEFAULT_X_SEARCH_MODEL, + }); + + let model = modelPick; + if (modelPick === "__custom__") { + const customModel = await ctx.prompter.text({ + message: "Custom Grok model name", + initialValue: existingModel || XAI_DEFAULT_X_SEARCH_MODEL, + placeholder: XAI_DEFAULT_X_SEARCH_MODEL, + }); + model = customModel.trim() || XAI_DEFAULT_X_SEARCH_MODEL; + } + + const next = structuredClone(ctx.config); + setPluginXSearchConfigValue(next, "enabled", true); + setPluginXSearchConfigValue(next, "model", model || XAI_DEFAULT_X_SEARCH_MODEL); + return next; +} + +function runXaiWebSearch(params: { + query: string; + model: string; + apiKey: string; + timeoutSeconds: number; + inlineCitations: boolean; + cacheTtlMs: number; +}): Promise> { + const cacheKey = normalizeCacheKey( + `grok:${params.model}:${String(params.inlineCitations)}:${params.query}`, + ); + const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey); + if (cached) { + return Promise.resolve({ ...cached.value, cached: true }); + } + + return (async () => { + const startedAt = Date.now(); + const result = await requestXaiWebSearch({ + query: params.query, + model: params.model, + apiKey: params.apiKey, + timeoutSeconds: params.timeoutSeconds, + inlineCitations: params.inlineCitations, + }); + const payload = buildXaiWebSearchPayload({ + query: params.query, + provider: "grok", + model: params.model, + tookMs: Date.now() - startedAt, + content: result.content, + citations: result.citations, + inlineCitations: result.inlineCitations, + }); + + writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + })(); +} + +function resolveXaiToolSearchConfig(ctx: { + config?: Record; + searchConfig?: Record; +}) { + return mergeScopedSearchConfig( + ctx.searchConfig, + "grok", + resolveProviderWebSearchPluginConfig(ctx.config, "xai"), + ); +} + +function resolveXaiWebSearchCredential(searchConfig?: Record): string | undefined { + return resolveWebSearchProviderCredential({ + credentialValue: getScopedCredentialValue(searchConfig, "grok"), + path: "tools.web.search.grok.apiKey", + envVars: ["XAI_API_KEY"], + }); +} + +export async function executeXaiWebSearchProviderTool( + ctx: { config?: Record; searchConfig?: Record }, + args: Record, +): Promise> { + const searchConfig = resolveXaiToolSearchConfig(ctx); + const apiKey = resolveXaiWebSearchCredential(searchConfig); + + 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 plugins.entries.xai.config.webSearch.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const query = readStringParam(args, "query", { required: true }); + void readNumberParam(args, "count", { integer: true }); + + return await runXaiWebSearch({ + query, + model: resolveXaiWebSearchModel(searchConfig), + apiKey, + timeoutSeconds: resolveTimeoutSeconds(searchConfig?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), + inlineCitations: resolveXaiInlineCitations(searchConfig), + cacheTtlMs: resolveCacheTtlMs(searchConfig?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), + }); +} + +export const __testing = { + buildXaiWebSearchPayload, + extractXaiWebSearchContent, + resolveXaiToolSearchConfig, + resolveXaiInlineCitations, + resolveXaiWebSearchCredential, + resolveXaiWebSearchModel, + requestXaiWebSearch, +}; diff --git a/extensions/xai/test-api.ts b/extensions/xai/test-api.ts new file mode 100644 index 00000000000..1f1a31cfcaa --- /dev/null +++ b/extensions/xai/test-api.ts @@ -0,0 +1 @@ +export { __testing } from "./src/web-search-provider.runtime.js"; diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index b5f732236fa..519a479ce84 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -6,7 +6,8 @@ import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js"; import { resolveXaiCatalogEntry } from "./model-definitions.js"; import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js"; -import { __testing, createXaiWebSearchProvider } from "./web-search.js"; +import { __testing } from "./test-api.js"; +import { createXaiWebSearchProvider } from "./web-search.js"; const { extractXaiWebSearchContent, diff --git a/extensions/xai/web-search.ts b/extensions/xai/web-search.ts index 50c8ea022b2..bfbca549cba 100644 --- a/extensions/xai/web-search.ts +++ b/extensions/xai/web-search.ts @@ -1,186 +1,29 @@ -import { Type } from "@sinclair/typebox"; import { - DEFAULT_CACHE_TTL_MINUTES, - DEFAULT_TIMEOUT_SECONDS, - formatCliCommand, - getScopedCredentialValue, - mergeScopedSearchConfig, - normalizeCacheKey, - readCache, - readNumberParam, - readStringParam, - resolveCacheTtlMs, - resolveProviderWebSearchPluginConfig, - resolveTimeoutSeconds, - resolveWebSearchProviderCredential, - setProviderWebSearchPluginConfigValue, - setScopedCredentialValue, - type WebSearchProviderSetupContext, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, - writeCache, -} from "openclaw/plugin-sdk/provider-web-search"; -import { - buildXaiWebSearchPayload, - extractXaiWebSearchContent, - requestXaiWebSearch, - resolveXaiInlineCitations, - resolveXaiWebSearchModel, -} from "./src/web-search-shared.js"; -import { - resolveEffectiveXSearchConfig, - setPluginXSearchConfigValue, -} from "./src/x-search-config.js"; -import { XAI_DEFAULT_X_SEARCH_MODEL } from "./src/x-search-shared.js"; + type WebSearchProviderSetupContext, +} from "openclaw/plugin-sdk/provider-web-search-config-contract"; -const XAI_WEB_SEARCH_CACHE = new Map< - string, - { value: Record; insertedAt: number; expiresAt: number } ->(); - -const X_SEARCH_MODEL_OPTIONS = [ - { - value: XAI_DEFAULT_X_SEARCH_MODEL, - label: XAI_DEFAULT_X_SEARCH_MODEL, - hint: "default · fast, no reasoning", +const XAI_CREDENTIAL_PATH = "plugins.entries.xai.config.webSearch.apiKey"; +const GenericXaiSearchSchema = { + 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, + }, }, - { - value: "grok-4-1-fast", - label: "grok-4-1-fast", - hint: "fast with reasoning", - }, -] as const; - -function resolveXSearchConfigRecord( - config?: WebSearchProviderSetupContext["config"], -): Record | undefined { - return resolveEffectiveXSearchConfig(config); -} + additionalProperties: false, +} satisfies Record; async function runXaiSearchProviderSetup( ctx: WebSearchProviderSetupContext, ): Promise { - const existingXSearch = resolveXSearchConfigRecord(ctx.config); - if (existingXSearch?.enabled === false) { - return ctx.config; - } - - await ctx.prompter.note( - [ - "x_search lets your agent search X (formerly Twitter) posts via xAI.", - "It reuses the same xAI API key you just configured for Grok web search.", - `You can change this later with ${formatCliCommand("openclaw configure --section web")}.`, - ].join("\n"), - "X search", - ); - - const enableChoice = await ctx.prompter.select<"yes" | "skip">({ - message: "Enable x_search too?", - options: [ - { - value: "yes", - label: "Yes, enable x_search", - hint: "Search X posts with the same xAI key", - }, - { - value: "skip", - label: "Skip for now", - hint: "Keep Grok web_search only", - }, - ], - initialValue: existingXSearch?.enabled === true || ctx.quickstartDefaults ? "yes" : "skip", - }); - - if (enableChoice === "skip") { - return ctx.config; - } - - const existingModel = - typeof existingXSearch?.model === "string" && existingXSearch.model.trim() - ? existingXSearch.model.trim() - : ""; - const knownModel = X_SEARCH_MODEL_OPTIONS.find((entry) => entry.value === existingModel)?.value; - const modelPick = await ctx.prompter.select({ - message: "Grok model for x_search", - options: [ - ...X_SEARCH_MODEL_OPTIONS, - { value: "__custom__", label: "Enter custom model name", hint: "" }, - ], - initialValue: knownModel ?? XAI_DEFAULT_X_SEARCH_MODEL, - }); - - let model = modelPick; - if (modelPick === "__custom__") { - const customModel = await ctx.prompter.text({ - message: "Custom Grok model name", - initialValue: existingModel || XAI_DEFAULT_X_SEARCH_MODEL, - placeholder: XAI_DEFAULT_X_SEARCH_MODEL, - }); - model = customModel.trim() || XAI_DEFAULT_X_SEARCH_MODEL; - } - - const next = structuredClone(ctx.config); - setPluginXSearchConfigValue(next, "enabled", true); - setPluginXSearchConfigValue(next, "model", model || XAI_DEFAULT_X_SEARCH_MODEL); - return next; -} - -function runXaiWebSearch(params: { - query: string; - model: string; - apiKey: string; - timeoutSeconds: number; - inlineCitations: boolean; - cacheTtlMs: number; -}): Promise> { - const cacheKey = normalizeCacheKey( - `grok:${params.model}:${String(params.inlineCitations)}:${params.query}`, - ); - const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey); - if (cached) { - return Promise.resolve({ ...cached.value, cached: true }); - } - - return (async () => { - const startedAt = Date.now(); - const result = await requestXaiWebSearch({ - query: params.query, - model: params.model, - apiKey: params.apiKey, - timeoutSeconds: params.timeoutSeconds, - inlineCitations: params.inlineCitations, - }); - const payload = buildXaiWebSearchPayload({ - query: params.query, - provider: "grok", - model: params.model, - tookMs: Date.now() - startedAt, - content: result.content, - citations: result.citations, - inlineCitations: result.inlineCitations, - }); - - writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - })(); -} - -function resolveXaiToolSearchConfig(ctx: { - config?: Record; - searchConfig?: Record; -}) { - return mergeScopedSearchConfig( - ctx.searchConfig, - "grok", - resolveProviderWebSearchPluginConfig(ctx.config, "xai"), - ); -} - -function resolveXaiWebSearchCredential(searchConfig?: Record): string | undefined { - return resolveWebSearchProviderCredential({ - credentialValue: getScopedCredentialValue(searchConfig, "grok"), - path: "tools.web.search.grok.apiKey", - envVars: ["XAI_API_KEY"], - }); + const runtime = await import("./src/web-search-provider.runtime.js"); + return await runtime.runXaiSearchProviderSetup(ctx); } export function createXaiWebSearchProvider(): WebSearchProviderPlugin { @@ -195,71 +38,22 @@ export function createXaiWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://console.x.ai/", docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 30, - credentialPath: "plugins.entries.xai.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.xai.config.webSearch.apiKey"], - getCredentialValue: (searchConfig?: Record) => - getScopedCredentialValue(searchConfig, "grok"), - setCredentialValue: (searchConfigTarget: Record, value: unknown) => - setScopedCredentialValue(searchConfigTarget, "grok", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "xai")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "xai", "apiKey", value); - }, + credentialPath: XAI_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: XAI_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "grok" }, + configuredCredential: { pluginId: "xai" }, + }), runSetup: runXaiSearchProviderSetup, - createTool: (ctx) => { - const searchConfig = resolveXaiToolSearchConfig(ctx); - return { - description: - "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.", - parameters: 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, - }), - ), - }), - execute: async (args: Record) => { - const apiKey = resolveXaiWebSearchCredential(searchConfig); - - 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 plugins.entries.xai.config.webSearch.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const query = readStringParam(args, "query", { required: true }); - void readNumberParam(args, "count", { integer: true }); - - return await runXaiWebSearch({ - query, - model: resolveXaiWebSearchModel(searchConfig), - apiKey, - timeoutSeconds: resolveTimeoutSeconds( - searchConfig?.timeoutSeconds, - DEFAULT_TIMEOUT_SECONDS, - ), - inlineCitations: resolveXaiInlineCitations(searchConfig), - cacheTtlMs: resolveCacheTtlMs(searchConfig?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), - }); - }, - }; - }, + createTool: (ctx) => ({ + description: + "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.", + parameters: GenericXaiSearchSchema, + execute: async (args) => { + const { executeXaiWebSearchProviderTool } = + await import("./src/web-search-provider.runtime.js"); + return await executeXaiWebSearchProviderTool(ctx, args); + }, + }), }; } - -export const __testing = { - buildXaiWebSearchPayload, - extractXaiWebSearchContent, - resolveXaiToolSearchConfig, - resolveXaiInlineCitations, - resolveXaiWebSearchCredential, - resolveXaiWebSearchModel, - requestXaiWebSearch, -};