!refactor(xai): move x_search config behind plugin boundary (#59674)

* refactor(xai): move x_search config behind plugin boundary

* chore(changelog): note x_search config migration

* fix(xai): include x_search migration helpers
This commit is contained in:
Vincent Koc
2026-04-02 22:08:59 +09:00
committed by GitHub
parent ef7c553dd1
commit 3e4de956c0
34 changed files with 788 additions and 420 deletions

View File

@@ -38,6 +38,30 @@
"label": "Enable Code Execution",
"help": "Enable the code_execution tool for remote xAI sandbox analysis."
},
"xSearch.enabled": {
"label": "Enable X Search",
"help": "Enable the x_search tool for searching X posts with xAI."
},
"xSearch.model": {
"label": "X Search Model",
"help": "xAI model override for x_search."
},
"xSearch.inlineCitations": {
"label": "X Search Inline Citations",
"help": "Keep inline markdown citations from xAI in x_search responses when available."
},
"xSearch.maxTurns": {
"label": "X Search Max Turns",
"help": "Optional max internal tool turns xAI may use per x_search request."
},
"xSearch.timeoutSeconds": {
"label": "X Search Timeout",
"help": "Timeout in seconds for x_search requests."
},
"xSearch.cacheTtlMinutes": {
"label": "X Search Cache TTL",
"help": "Cache TTL in minutes for x_search results."
},
"codeExecution.model": {
"label": "Code Execution Model",
"help": "xAI model override for code_execution."
@@ -74,6 +98,30 @@
}
}
},
"xSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"model": {
"type": "string"
},
"inlineCitations": {
"type": "boolean"
},
"maxTurns": {
"type": "number"
},
"timeoutSeconds": {
"type": "number"
},
"cacheTtlMinutes": {
"type": "number"
}
}
},
"codeExecution": {
"type": "object",
"additionalProperties": false,

View File

@@ -0,0 +1,56 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
type JsonRecord = Record<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cloneRecord<T extends JsonRecord | undefined>(value: T): T {
if (!value) {
return value;
}
return { ...value } as T;
}
export function resolveLegacyXSearchConfig(config?: OpenClawConfig): JsonRecord | undefined {
const web = config?.tools?.web as Record<string, unknown> | undefined;
const xSearch = web?.x_search;
return isRecord(xSearch) ? cloneRecord(xSearch) : undefined;
}
export function resolvePluginXSearchConfig(config?: OpenClawConfig): JsonRecord | undefined {
const pluginConfig = config?.plugins?.entries?.xai?.config;
if (!isRecord(pluginConfig?.xSearch)) {
return undefined;
}
return cloneRecord(pluginConfig.xSearch);
}
export function resolveEffectiveXSearchConfig(config?: OpenClawConfig): JsonRecord | undefined {
const legacy = resolveLegacyXSearchConfig(config);
const pluginOwned = resolvePluginXSearchConfig(config);
if (!legacy) {
return pluginOwned;
}
if (!pluginOwned) {
return legacy;
}
return {
...legacy,
...pluginOwned,
};
}
export function setPluginXSearchConfigValue(
configTarget: OpenClawConfig,
key: string,
value: unknown,
): void {
const plugins = (configTarget.plugins ??= {}) as { entries?: Record<string, unknown> };
const entries = (plugins.entries ??= {});
const entry = (entries.xai ??= {}) as { config?: Record<string, unknown> };
const config = (entry.config ??= {});
const xSearch = (config.xSearch ??= {}) as Record<string, unknown>;
xSearch[key] = value;
}

View File

@@ -112,7 +112,7 @@ describe("xai web search config resolution", () => {
});
});
it("offers plugin-owned x_search setup after Grok is selected", async () => {
it("offers plugin-owned xSearch setup after Grok is selected", async () => {
const provider = createXaiWebSearchProvider();
const select = vi.fn().mockResolvedValueOnce("yes").mockResolvedValueOnce("grok-4-1-fast");
const prompter = createWizardPrompter({
@@ -146,24 +146,32 @@ describe("xai web search config resolution", () => {
prompter,
});
expect(next?.tools?.web?.x_search).toMatchObject({
expect(next?.plugins?.entries?.xai?.config?.xSearch).toMatchObject({
enabled: true,
model: "grok-4-1-fast",
});
});
it("keeps explicit x_search disablement untouched during provider-owned setup", async () => {
it("keeps explicit xSearch disablement untouched during provider-owned setup", async () => {
const provider = createXaiWebSearchProvider();
const config = {
plugins: {
entries: {
xai: {
config: {
xSearch: {
enabled: false,
},
},
},
},
},
tools: {
web: {
search: {
provider: "grok",
enabled: true,
},
x_search: {
enabled: false,
},
},
},
};

View File

@@ -27,6 +27,10 @@ import {
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";
const XAI_WEB_SEARCH_CACHE = new Map<
@@ -50,28 +54,7 @@ const X_SEARCH_MODEL_OPTIONS = [
function resolveXSearchConfigRecord(
config?: WebSearchProviderSetupContext["config"],
): Record<string, unknown> | undefined {
const xSearch = config?.tools?.web?.x_search;
return xSearch && typeof xSearch === "object" ? (xSearch as Record<string, unknown>) : undefined;
}
function applyXSearchSetupConfig(
config: WebSearchProviderSetupContext["config"],
params: { enabled: boolean; model: string },
): WebSearchProviderSetupContext["config"] {
return {
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
x_search: {
...config.tools?.web?.x_search,
enabled: params.enabled,
model: params.model,
},
},
},
};
return resolveEffectiveXSearchConfig(config);
}
async function runXaiSearchProviderSetup(
@@ -136,10 +119,10 @@ async function runXaiSearchProviderSetup(
model = customModel.trim() || XAI_DEFAULT_X_SEARCH_MODEL;
}
return applyXSearchSetupConfig(ctx.config, {
enabled: true,
model: model || 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: {

View File

@@ -10,11 +10,15 @@ describeLive("xai x_search live", () => {
it("queries X through xAI Responses", async () => {
const tool = createXSearchTool({
config: {
tools: {
web: {
x_search: {
enabled: true,
model: "grok-4-1-fast-non-reasoning",
plugins: {
entries: {
xai: {
config: {
xSearch: {
enabled: true,
model: "grok-4-1-fast-non-reasoning",
},
},
},
},
},

View File

@@ -44,14 +44,18 @@ afterEach(() => {
});
describe("xai x_search tool", () => {
it("enables x_search when runtime metadata marks an xAI key active", () => {
it("enables x_search when runtime config carries the shared xAI key", () => {
const tool = createXSearchTool({
config: {},
runtimeConfig: {
tools: {
web: {
x_search: {
apiKey: "x-search-runtime-key", // pragma: allowlist secret
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "x-search-runtime-key", // pragma: allowlist secret
},
},
},
},
},
@@ -85,12 +89,18 @@ describe("xai x_search tool", () => {
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,
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-config-test", // pragma: allowlist secret
},
xSearch: {
model: "grok-4-1-fast-non-reasoning",
maxTurns: 2,
},
},
},
},
},
@@ -154,23 +164,31 @@ describe("xai x_search tool", () => {
);
});
it("prefers the active runtime config for SecretRef-backed x_search keys", async () => {
it("prefers the active runtime config for shared xAI keys", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
tools: {
web: {
x_search: {
apiKey: { source: "env", provider: "default", id: "X_SEARCH_KEY_REF" },
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "X_SEARCH_KEY_REF" },
},
},
},
},
},
},
runtimeConfig: {
tools: {
web: {
x_search: {
apiKey: "x-search-runtime-key", // pragma: allowlist secret
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "x-search-runtime-key", // pragma: allowlist secret
},
},
},
},
},
@@ -217,10 +235,14 @@ describe("xai x_search tool", () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
tools: {
web: {
x_search: {
apiKey: "xai-config-test", // pragma: allowlist secret
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-config-test", // pragma: allowlist secret
},
},
},
},
},

View File

@@ -13,6 +13,10 @@ import {
resolveTimeoutSeconds,
writeCache,
} from "openclaw/plugin-sdk/provider-web-search";
import {
resolveEffectiveXSearchConfig,
resolveLegacyXSearchConfig,
} from "./src/x-search-config.js";
import {
buildXaiXSearchPayload,
requestXaiXSearch,
@@ -22,12 +26,6 @@ import {
type XaiXSearchOptions,
} from "./src/x-search-shared.js";
type XSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { x_search?: infer XSearch }
? XSearch
: undefined
: undefined;
class PluginToolInputError extends Error {
constructor(message: string) {
super(message);
@@ -79,39 +77,32 @@ 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 readLegacyXSearchApiKey(cfg?: OpenClawConfig): string | undefined {
const legacyConfig = resolveLegacyXSearchConfig(cfg);
return readConfiguredSecretString(legacyConfig?.apiKey, "tools.web.x_search.apiKey");
}
function resolveXSearchConfig(cfg?: OpenClawConfig): Record<string, unknown> | undefined {
return resolveEffectiveXSearchConfig(cfg);
}
function resolveXSearchEnabled(params: {
cfg?: OpenClawConfig;
config?: XSearchConfig;
config?: Record<string, unknown>;
runtimeConfig?: OpenClawConfig;
}): boolean {
if (params.config?.enabled === false) {
return false;
}
const runtimeXSearchConfig =
params.runtimeConfig && params.runtimeConfig !== params.cfg
? resolveXSearchConfig(params.runtimeConfig)
: undefined;
if (
readConfiguredSecretString(runtimeXSearchConfig?.apiKey, "tools.web.x_search.apiKey") ||
resolveFallbackXaiApiKey(params.runtimeConfig)
resolveFallbackXaiApiKey(params.runtimeConfig) ||
readLegacyXSearchApiKey(params.runtimeConfig)
) {
return true;
}
const configuredApiKey = readConfiguredSecretString(
params.config?.apiKey,
"tools.web.x_search.apiKey",
);
return Boolean(
configuredApiKey ||
resolveFallbackXaiApiKey(params.cfg) ||
readLegacyXSearchApiKey(params.cfg) ||
readProviderEnvValue(["XAI_API_KEY"]),
);
}
@@ -126,10 +117,10 @@ function resolveXSearchApiKey(params: {
? resolveXSearchConfig(params.runtimeConfig)
: undefined;
return (
readConfiguredSecretString(runtimeXSearchConfig?.apiKey, "tools.web.x_search.apiKey") ??
readConfiguredSecretString(sourceXSearchConfig?.apiKey, "tools.web.x_search.apiKey") ??
resolveFallbackXaiApiKey(params.runtimeConfig) ??
resolveFallbackXaiApiKey(params.sourceConfig) ??
readLegacyXSearchApiKey(params.runtimeConfig) ??
readLegacyXSearchApiKey(params.sourceConfig) ??
readProviderEnvValue(["XAI_API_KEY"])
);
}
@@ -234,7 +225,7 @@ export function createXSearchTool(options?: {
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.",
"x_search 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",
});
}