mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 03:10:21 +00:00
refactor(xai): move x_search into plugin
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user