test: lazy-load xai web search runtime

Keep xAI web-search provider registration metadata-light and move
setup, execution, cache, and test helpers behind runtime seams.
This commit is contained in:
Gustavo Madeira Santana
2026-04-17 17:37:37 -04:00
parent 141c7f8eaa
commit 1da928211b
4 changed files with 253 additions and 241 deletions

View File

@@ -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<string, unknown>; 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<string, unknown> | undefined {
return resolveEffectiveXSearchConfig(config);
}
export async function runXaiSearchProviderSetup(
ctx: WebSearchProviderSetupContext,
): Promise<WebSearchProviderSetupContext["config"]> {
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<string>({
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<Record<string, unknown>> {
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<string, unknown>;
searchConfig?: Record<string, unknown>;
}) {
return mergeScopedSearchConfig(
ctx.searchConfig,
"grok",
resolveProviderWebSearchPluginConfig(ctx.config, "xai"),
);
}
function resolveXaiWebSearchCredential(searchConfig?: Record<string, unknown>): 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<string, unknown>; searchConfig?: Record<string, unknown> },
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
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,
};

View File

@@ -0,0 +1 @@
export { __testing } from "./src/web-search-provider.runtime.js";

View File

@@ -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,

View File

@@ -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<string, unknown>; 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<string, unknown> | undefined {
return resolveEffectiveXSearchConfig(config);
}
additionalProperties: false,
} satisfies Record<string, unknown>;
async function runXaiSearchProviderSetup(
ctx: WebSearchProviderSetupContext,
): Promise<WebSearchProviderSetupContext["config"]> {
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<string>({
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<Record<string, unknown>> {
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<string, unknown>;
searchConfig?: Record<string, unknown>;
}) {
return mergeScopedSearchConfig(
ctx.searchConfig,
"grok",
resolveProviderWebSearchPluginConfig(ctx.config, "xai"),
);
}
function resolveXaiWebSearchCredential(searchConfig?: Record<string, unknown>): 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<string, unknown>) =>
getScopedCredentialValue(searchConfig, "grok"),
setCredentialValue: (searchConfigTarget: Record<string, unknown>, 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<string, unknown>) => {
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,
};