refactor(xai): move x_search into plugin

This commit is contained in:
Peter Steinberger
2026-03-28 19:13:09 +00:00
parent 396bf20cc6
commit 2a950157b1
13 changed files with 550 additions and 430 deletions

View File

@@ -1,4 +1,3 @@
export { createCodeExecutionTool } from "./code-execution.js";
export { createWebFetchTool, extractReadableContent, fetchFirecrawlContent } from "./web-fetch.js";
export { createWebSearchTool } from "./web-search.js";
export { createXSearchTool } from "./x-search.js";
export { createCodeExecutionTool } from "./code-execution.js";

View File

@@ -1,202 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { createXSearchTool } from "./x-search.js";
function installXSearchFetch(payload?: Record<string, unknown>) {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve(
payload ?? {
output: [
{
type: "message",
content: [
{
type: "output_text",
text: "Found X posts",
annotations: [{ type: "url_citation", url: "https://x.com/openclaw/status/1" }],
},
],
},
],
citations: ["https://x.com/openclaw/status/1"],
},
),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
}
function parseFirstRequestBody(mockFetch: ReturnType<typeof installXSearchFetch>) {
const request = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined;
const requestBody = request?.body;
return JSON.parse(typeof requestBody === "string" ? requestBody : "{}") as Record<
string,
unknown
>;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("x_search tool", () => {
it("enables x_search when runtime metadata marks an xAI key active", () => {
const tool = createXSearchTool({
config: {},
runtimeXSearch: {
active: true,
apiKeySource: "env",
diagnostics: [],
},
});
expect(tool?.name).toBe("x_search");
});
it("enables x_search when the xAI plugin web search key is configured", () => {
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
},
},
},
},
},
},
});
expect(tool?.name).toBe("x_search");
});
it("uses the xAI Responses x_search tool with structured filters", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
tools: {
web: {
x_search: {
apiKey: "xai-config-test", // pragma: allowlist secret
model: "grok-4-1-fast-non-reasoning",
maxTurns: 2,
},
},
},
},
});
const result = await tool?.execute?.("x-search:1", {
query: "dinner recipes",
allowed_x_handles: ["openclaw"],
excluded_x_handles: ["spam"],
from_date: "2026-03-01",
to_date: "2026-03-20",
enable_image_understanding: true,
});
expect(mockFetch).toHaveBeenCalled();
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("api.x.ai/v1/responses");
const body = parseFirstRequestBody(mockFetch);
expect(body.model).toBe("grok-4-1-fast-non-reasoning");
expect(body.max_turns).toBe(2);
expect(body.tools).toEqual([
{
type: "x_search",
allowed_x_handles: ["openclaw"],
excluded_x_handles: ["spam"],
from_date: "2026-03-01",
to_date: "2026-03-20",
enable_image_understanding: true,
},
]);
expect((result?.details as { citations?: string[] } | undefined)?.citations).toEqual([
"https://x.com/openclaw/status/1",
]);
});
it("reuses the xAI plugin web search key for x_search requests", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
},
},
},
},
},
},
});
await tool?.execute?.("x-search:plugin-key", {
query: "latest post from huntharo",
});
const request = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect((request?.headers as Record<string, string> | undefined)?.Authorization).toBe(
"Bearer xai-plugin-key",
);
});
it("reuses the legacy grok web search key for x_search requests", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
tools: {
web: {
search: {
grok: {
apiKey: "xai-legacy-key", // pragma: allowlist secret
},
},
},
},
},
});
await tool?.execute?.("x-search:legacy-key", {
query: "latest legacy-key post from huntharo",
});
const request = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect((request?.headers as Record<string, string> | undefined)?.Authorization).toBe(
"Bearer xai-legacy-key",
);
});
it("rejects invalid date ordering before calling xAI", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
tools: {
web: {
x_search: {
apiKey: "xai-config-test", // pragma: allowlist secret
},
},
},
},
});
await expect(
tool?.execute?.("x-search:bad-dates", {
query: "dinner recipes",
from_date: "2026-03-20",
to_date: "2026-03-01",
}),
).rejects.toThrow(/from_date must be on or before to_date/i);
expect(mockFetch).not.toHaveBeenCalled();
});
});

View File

@@ -1,270 +0,0 @@
import { Type } from "@sinclair/typebox";
import {
buildXaiXSearchPayload,
requestXaiXSearch,
resolveXaiXSearchInlineCitations,
resolveXaiXSearchMaxTurns,
resolveXaiXSearchModel,
type XaiXSearchOptions,
} from "../../../extensions/xai/x-search.js";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveProviderWebSearchPluginConfig } from "../../plugin-sdk/provider-web-search.js";
import type { RuntimeWebXSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
import { jsonResult, readStringArrayParam, readStringParam, ToolInputError } from "./common.js";
import {
readConfiguredSecretString,
readProviderEnvValue,
SEARCH_CACHE,
} from "./web-search-provider-common.js";
import { readCache, resolveCacheTtlMs, resolveTimeoutSeconds, writeCache } from "./web-shared.js";
type XSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { x_search?: infer XSearch }
? XSearch
: undefined
: undefined;
function readLegacyGrokApiKey(cfg?: OpenClawConfig): string | undefined {
const search = cfg?.tools?.web?.search;
if (!search || typeof search !== "object") {
return undefined;
}
const grok = (search as Record<string, unknown>).grok;
return readConfiguredSecretString(
grok && typeof grok === "object" ? (grok as Record<string, unknown>).apiKey : undefined,
"tools.web.search.grok.apiKey",
);
}
function readPluginXaiWebSearchApiKey(cfg?: OpenClawConfig): string | undefined {
return readConfiguredSecretString(
resolveProviderWebSearchPluginConfig(cfg as Record<string, unknown> | undefined, "xai")?.apiKey,
"plugins.entries.xai.config.webSearch.apiKey",
);
}
function resolveFallbackXaiApiKey(cfg?: OpenClawConfig): string | undefined {
return readPluginXaiWebSearchApiKey(cfg) ?? readLegacyGrokApiKey(cfg);
}
function resolveXSearchConfig(cfg?: OpenClawConfig): XSearchConfig {
const xSearch = cfg?.tools?.web?.x_search;
if (!xSearch || typeof xSearch !== "object") {
return undefined;
}
return xSearch as XSearchConfig;
}
function resolveXSearchEnabled(params: {
cfg?: OpenClawConfig;
config?: XSearchConfig;
runtimeXSearch?: RuntimeWebXSearchMetadata;
}): boolean {
if (params.config?.enabled === false) {
return false;
}
if (params.runtimeXSearch?.active) {
return true;
}
const configuredApiKey = readConfiguredSecretString(
params.config?.apiKey,
"tools.web.x_search.apiKey",
);
return Boolean(
configuredApiKey ||
resolveFallbackXaiApiKey(params.cfg) ||
readProviderEnvValue(["XAI_API_KEY"]),
);
}
function resolveXSearchApiKey(config?: XSearchConfig, cfg?: OpenClawConfig): string | undefined {
return (
readConfiguredSecretString(config?.apiKey, "tools.web.x_search.apiKey") ??
resolveFallbackXaiApiKey(cfg) ??
readProviderEnvValue(["XAI_API_KEY"])
);
}
function normalizeOptionalIsoDate(value: string | undefined, label: string): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
throw new ToolInputError(`${label} must use YYYY-MM-DD`);
}
const [year, month, day] = trimmed.split("-").map((entry) => Number.parseInt(entry, 10));
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
throw new ToolInputError(`${label} must be a valid calendar date`);
}
return trimmed;
}
function buildXSearchCacheKey(params: {
query: string;
model: string;
inlineCitations: boolean;
maxTurns?: number;
options: Omit<XaiXSearchOptions, "query">;
}) {
return JSON.stringify([
"x_search",
params.model,
params.query,
params.inlineCitations,
params.maxTurns ?? null,
params.options.allowedXHandles ?? null,
params.options.excludedXHandles ?? null,
params.options.fromDate ?? null,
params.options.toDate ?? null,
params.options.enableImageUnderstanding ?? false,
params.options.enableVideoUnderstanding ?? false,
]);
}
export function createXSearchTool(options?: {
config?: OpenClawConfig;
runtimeXSearch?: RuntimeWebXSearchMetadata;
}) {
const xSearchConfig = resolveXSearchConfig(options?.config);
if (
!resolveXSearchEnabled({
cfg: options?.config,
config: xSearchConfig,
runtimeXSearch: options?.runtimeXSearch,
})
) {
return null;
}
return {
label: "X Search",
name: "x_search",
description:
"Search X (formerly Twitter) using xAI, including targeted post or thread lookups. For per-post stats like reposts, replies, bookmarks, or views, prefer the exact post URL or status ID.",
parameters: Type.Object({
query: Type.String({ description: "X search query string." }),
allowed_x_handles: Type.Optional(
Type.Array(Type.String({ minLength: 1 }), {
description: "Only include posts from these X handles.",
}),
),
excluded_x_handles: Type.Optional(
Type.Array(Type.String({ minLength: 1 }), {
description: "Exclude posts from these X handles.",
}),
),
from_date: Type.Optional(
Type.String({ description: "Only include posts on or after this date (YYYY-MM-DD)." }),
),
to_date: Type.Optional(
Type.String({ description: "Only include posts on or before this date (YYYY-MM-DD)." }),
),
enable_image_understanding: Type.Optional(
Type.Boolean({ description: "Allow xAI to inspect images attached to matching posts." }),
),
enable_video_understanding: Type.Optional(
Type.Boolean({ description: "Allow xAI to inspect videos attached to matching posts." }),
),
}),
execute: async (_toolCallId: string, args: Record<string, unknown>) => {
const apiKey = resolveXSearchApiKey(xSearchConfig, options?.config);
if (!apiKey) {
return jsonResult({
error: "missing_xai_api_key",
message:
"x_search needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.x_search.apiKey or plugins.entries.xai.config.webSearch.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
});
}
const query = readStringParam(args, "query", { required: true });
const allowedXHandles = readStringArrayParam(args, "allowed_x_handles");
const excludedXHandles = readStringArrayParam(args, "excluded_x_handles");
const fromDate = normalizeOptionalIsoDate(readStringParam(args, "from_date"), "from_date");
const toDate = normalizeOptionalIsoDate(readStringParam(args, "to_date"), "to_date");
if (fromDate && toDate && fromDate > toDate) {
throw new ToolInputError("from_date must be on or before to_date");
}
const xSearchOptions: XaiXSearchOptions = {
query,
allowedXHandles,
excludedXHandles,
fromDate,
toDate,
enableImageUnderstanding: args.enable_image_understanding === true,
enableVideoUnderstanding: args.enable_video_understanding === true,
};
const xSearchConfigRecord = xSearchConfig as Record<string, unknown> | undefined;
const model = resolveXaiXSearchModel(xSearchConfigRecord);
const inlineCitations = resolveXaiXSearchInlineCitations(xSearchConfigRecord);
const maxTurns = resolveXaiXSearchMaxTurns(xSearchConfigRecord);
const cacheKey = buildXSearchCacheKey({
query,
model,
inlineCitations,
maxTurns,
options: {
allowedXHandles,
excludedXHandles,
fromDate,
toDate,
enableImageUnderstanding: xSearchOptions.enableImageUnderstanding,
enableVideoUnderstanding: xSearchOptions.enableVideoUnderstanding,
},
});
const cached = readCache(SEARCH_CACHE, cacheKey);
if (cached) {
return jsonResult({ ...cached.value, cached: true });
}
const startedAt = Date.now();
const result = await requestXaiXSearch({
apiKey,
model,
timeoutSeconds: resolveTimeoutSeconds(xSearchConfig?.timeoutSeconds, 30),
inlineCitations,
maxTurns,
options: xSearchOptions,
});
const payload = buildXaiXSearchPayload({
query,
model,
tookMs: Date.now() - startedAt,
content: result.content,
citations: result.citations,
inlineCitations: result.inlineCitations,
options: xSearchOptions,
});
writeCache(
SEARCH_CACHE,
cacheKey,
payload,
resolveCacheTtlMs(xSearchConfig?.cacheTtlMinutes, 15),
);
return jsonResult(payload);
},
};
}
export const __testing = {
buildXSearchCacheKey,
buildXaiXSearchPayload,
normalizeOptionalIsoDate,
requestXaiXSearch,
resolveXaiXSearchInlineCitations,
resolveXaiXSearchMaxTurns,
resolveXaiXSearchModel,
resolveXSearchApiKey,
resolveXSearchConfig,
resolveXSearchEnabled,
} as const;