From 5ebe040eaa039c47ff1330b950a0e912a54baf66 Mon Sep 17 00:00:00 2001 From: krissding Date: Fri, 10 Jul 2026 12:30:23 +0800 Subject: [PATCH] fix(xai): require consent for cross-provider billed tools (#97629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(xai): gate billed tools by active provider Co-authored-by: 丁宇婷0668001435 * chore: remove contributor changelog entry --------- Co-authored-by: Peter Steinberger --- docs/providers/xai.md | 39 ++++--- docs/tools/code-execution.md | 21 +++- docs/tools/web.md | 14 ++- extensions/xai/index.test.ts | 155 ++++++++++++++++++++++++++++ extensions/xai/index.ts | 46 ++++++--- extensions/xai/openclaw.plugin.json | 4 +- extensions/xai/xai.live.test.ts | 86 +++++++++++++++ 7 files changed, 330 insertions(+), 35 deletions(-) diff --git a/docs/providers/xai.md b/docs/providers/xai.md index 628efd8a846d..9924fa7c669b 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -194,6 +194,15 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20 ## Features + + `x_search` and `code_execution` run on xAI's servers. xAI bills $5 per 1,000 + tool calls, plus the model's input and output tokens. With each tool's + `enabled` setting omitted, OpenClaw exposes it only for an active xAI model. + A known non-xAI model provider requires an explicit per-tool `enabled: true`; + a missing or unresolved provider fails closed. xAI auth is always required, + and `enabled: false` disables the tool for every provider. + + The bundled `grok` web-search provider prefers xAI OAuth, then falls back @@ -429,15 +438,15 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20 Config path: `plugins.entries.xai.config.xSearch` - | Key | Type | Default | Description | - | ----------------- | ------- | ------------------------------ | ------------------------------------- | - | `enabled` | boolean | `true` (if key available) | Enable or disable x_search | - | `model` | string | `grok-4.3` | Model used for x_search requests | - | `baseUrl` | string | - | xAI Responses base URL override | - | `inlineCitations` | boolean | - | Include inline citations in results | - | `maxTurns` | number | - | Maximum conversation turns | - | `timeoutSeconds` | number | `30` | Request timeout in seconds | - | `cacheTtlMinutes` | number | `15` | Cache time-to-live in minutes | + | Key | Type | Default | Description | + | ----------------- | ------- | ------------------------- | ------------------------------------------------ | + | `enabled` | boolean | Automatic for xAI models | Disable, or opt in for a known non-xAI provider | + | `model` | string | `grok-4.3` | Model used for x_search requests | + | `baseUrl` | string | - | xAI Responses base URL override | + | `inlineCitations` | boolean | - | Include inline citations in results | + | `maxTurns` | number | - | Maximum conversation turns | + | `timeoutSeconds` | number | `30` | Request timeout in seconds | + | `cacheTtlMinutes` | number | `15` | Cache time-to-live in minutes | ```json5 { @@ -466,12 +475,12 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20 Config path: `plugins.entries.xai.config.codeExecution` - | Key | Type | Default | Description | - | ---------------- | ------- | -------------------------- | ---------------------------------------- | - | `enabled` | boolean | `true` (if key available) | Enable or disable code execution | - | `model` | string | `grok-4.3` | Model used for code execution requests | - | `maxTurns` | number | - | Maximum conversation turns | - | `timeoutSeconds` | number | `30` | Request timeout in seconds | + | Key | Type | Default | Description | + | ---------------- | ------- | ------------------------ | ------------------------------------------------ | + | `enabled` | boolean | Automatic for xAI models | Disable, or opt in for a known non-xAI provider | + | `model` | string | `grok-4.3` | Model used for code execution requests | + | `maxTurns` | number | - | Maximum conversation turns | + | `timeoutSeconds` | number | `30` | Request timeout in seconds | This is remote xAI sandbox execution, not local [`exec`](/tools/exec). diff --git a/docs/tools/code-execution.md b/docs/tools/code-execution.md index c52d993f49d8..5d099322bd07 100644 --- a/docs/tools/code-execution.md +++ b/docs/tools/code-execution.md @@ -11,6 +11,11 @@ title: "Code execution" (`https://api.x.ai/v1/responses`, same endpoint `x_search` uses). It is registered by the bundled `xai` plugin under the `tools` contract. + + `code_execution` runs on xAI's servers. xAI bills $5 per 1,000 tool calls, + plus the model's input and output tokens. + + | Property | Value | | ------------------ | --------------------------------------------------------------------------------- | | Tool name | `code_execution` | @@ -77,9 +82,15 @@ For local execution, use [`exec`](/tools/exec) instead. - `code_execution` is available whenever xAI credentials resolve. Set - `plugins.entries.xai.config.codeExecution.enabled` to `false` to disable - it, or use the same block to override the model, turn cap, or timeout: + With `enabled` omitted, `code_execution` is exposed only when the active + model's provider is `xai` and xAI credentials resolve. For an active model + with a known non-xAI provider, set + `plugins.entries.xai.config.codeExecution.enabled` to `true` to opt in to + cross-provider use. If the active model provider is missing or unresolved, + the tool stays hidden. Set `enabled` to `false` to disable it for every + provider. xAI credentials are always required. + + Use the same block to override the model, turn cap, or timeout: ```json5 { @@ -88,7 +99,7 @@ For local execution, use [`exec`](/tools/exec) instead. xai: { config: { codeExecution: { - enabled: true, + enabled: true, // required for a known non-xAI model provider model: "grok-4.3", // override the default xAI code-execution model maxTurns: 2, // optional cap on internal tool turns timeoutSeconds: 30, // request timeout (default: 30) @@ -108,7 +119,7 @@ For local execution, use [`exec`](/tools/exec) instead. ``` `code_execution` appears in the agent's tool list once the xAI plugin - re-registers with `enabled: true`. + re-registers and the provider, enablement, and auth checks above pass. diff --git a/docs/tools/web.md b/docs/tools/web.md index 2ddba95b9c33..03494450d8ef 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -418,6 +418,11 @@ optional structured filters. OpenClaw constructs the built-in xAI `x_search` tool per request rather than keeping it permanently registered, so it is only active for the turn that actually calls it. + + `x_search` runs on xAI's servers. xAI bills $5 per 1,000 tool calls, plus the + model's input and output tokens. + + xAI documents `x_search` as supporting keyword search, semantic search, user search, and thread fetch. For per-post engagement stats such as reposts, @@ -429,6 +434,13 @@ active for the turn that actually calls it. ### x_search config +With `enabled` omitted, `x_search` is exposed only when the active model's +provider is `xai` and xAI credentials resolve. For an active model with a known +non-xAI provider, set `plugins.entries.xai.config.xSearch.enabled` to `true` to +opt in to cross-provider use. If the active model provider is missing or +unresolved, the tool stays hidden. Set `enabled` to `false` to disable it for +every provider. xAI credentials are always required. + ```json5 { plugins: { @@ -436,7 +448,7 @@ active for the turn that actually calls it. xai: { config: { xSearch: { - enabled: true, + enabled: true, // required for a known non-xAI model provider model: "grok-4.3", baseUrl: "https://api.x.ai/v1", // optional, overrides webSearch.baseUrl inlineCitations: false, diff --git a/extensions/xai/index.test.ts b/extensions/xai/index.test.ts index c78c582360bd..2853f8b3e0dc 100644 --- a/extensions/xai/index.test.ts +++ b/extensions/xai/index.test.ts @@ -73,14 +73,58 @@ function requireEntry(entries: T[], id: string): T { return entry; } +type XaiBilledToolName = "code_execution" | "x_search"; + +function registerXaiBilledToolFactories() { + const tools = new Map[0]>(); + plugin.register( + createTestPluginApi({ + registerTool(tool, opts) { + if (opts?.name) { + tools.set(opts.name, tool); + } + }, + }), + ); + + function requireFactory(name: XaiBilledToolName) { + const factory = tools.get(name); + if (typeof factory !== "function") { + throw new Error(`Expected ${name} to register a tool factory`); + } + return factory; + } + + return { + code_execution: requireFactory("code_execution"), + x_search: requireFactory("x_search"), + }; +} + +function createXaiBilledToolConfig(name: XaiBilledToolName, enabled?: boolean) { + const toolConfig = enabled === undefined ? {} : { enabled }; + return { + plugins: { + entries: { + xai: { + config: + name === "code_execution" ? { codeExecution: toolConfig } : { xSearch: toolConfig }, + }, + }, + }, + }; +} + describe("xai provider plugin", () => { beforeEach(() => { clearLiveCatalogCacheForTests(); providerAuthRuntimeMocks.resolveApiKeyForProvider.mockReset(); + vi.stubEnv("XAI_API_KEY", ""); }); afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it("exposes xAI OAuth and preserves the explicit device-code alias", async () => { @@ -450,6 +494,117 @@ describe("xai provider plugin", () => { expect(realtimeProvider.aliases).toContain("xai-realtime"); }); + describe.each(["code_execution", "x_search"] as const)("%s exposure", (toolName) => { + it.each([ + { + label: "exposes by default for an xAI model with auth", + provider: "xai", + hasAuth: true, + expected: true, + }, + { + label: "exposes when explicitly enabled for an xAI model with auth", + provider: "xai", + enabled: true, + hasAuth: true, + expected: true, + }, + { + label: "hides when explicitly disabled for an xAI model", + provider: "xai", + enabled: false, + hasAuth: true, + expected: false, + }, + { + label: "hides by default for a known non-xAI model", + provider: "openai", + hasAuth: true, + expected: false, + }, + { + label: "hides when explicitly disabled for a known non-xAI model", + provider: "openai", + enabled: false, + hasAuth: true, + expected: false, + }, + { + label: "exposes when explicitly enabled for a known non-xAI model with auth", + provider: "openai", + enabled: true, + hasAuth: true, + expected: true, + }, + { + label: "hides when the active provider is missing", + enabled: true, + hasAuth: true, + expected: false, + }, + { + label: "hides when the active provider is blank", + provider: " ", + enabled: true, + hasAuth: true, + expected: false, + }, + { + label: "hides an xAI model without auth", + provider: "xai", + hasAuth: false, + expected: false, + }, + { + label: "hides an explicit non-xAI opt-in without auth", + provider: "openai", + enabled: true, + hasAuth: false, + expected: false, + }, + ])("$label", ({ provider, enabled, hasAuth, expected }) => { + const factory = registerXaiBilledToolFactories()[toolName]; + const tool = factory({ + config: createXaiBilledToolConfig(toolName, enabled), + activeModel: provider === undefined ? {} : { provider }, + hasAuthForProvider: (providerId) => hasAuth && providerId === "xai", + resolveApiKeyForProvider: async (providerId) => + hasAuth && providerId === "xai" ? "xai-test-key" : undefined, + }); + + expect(tool).toEqual(expected ? expect.objectContaining({ name: toolName }) : null); + }); + + it.each([ + { + label: "runtime false overrides source true", + provider: "xai", + sourceEnabled: true, + runtimeEnabled: false, + expected: false, + }, + { + label: "runtime true overrides source false for a known non-xAI provider", + provider: "openai", + sourceEnabled: false, + runtimeEnabled: true, + expected: true, + }, + ])("$label", ({ provider, sourceEnabled, runtimeEnabled, expected }) => { + const factory = registerXaiBilledToolFactories()[toolName]; + const tool = factory({ + config: createXaiBilledToolConfig(toolName, sourceEnabled), + runtimeConfig: createXaiBilledToolConfig(toolName, runtimeEnabled), + activeModel: { provider }, + hasAuthForProvider: (providerId) => providerId === "xai", + resolveApiKeyForProvider: async (providerId) => + providerId === "xai" ? "xai-test-key" : undefined, + }); + + expect(tool).toEqual(expected ? expect.objectContaining({ name: toolName }) : null); + }); + }); + it("declares setup auto-enable reasons for plugin-owned tool config", () => { const probe = registerXaiAutoEnableProbe(); diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index e4849f7214b2..29919a40d692 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -1,5 +1,6 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; // Xai plugin entrypoint registers its OpenClaw integration. +import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { OPENAI_COMPATIBLE_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared"; import { defaultToolStreamExtraParams } from "openclaw/plugin-sdk/provider-stream-shared"; @@ -92,13 +93,30 @@ function isXSearchEnabled(config: unknown, auth?: XaiToolAuthContext): boolean { return hasResolvableXaiApiKey(config, auth); } -function createLazyCodeExecutionTool(ctx: { - config?: Record; - runtimeConfig?: Record; - hasAuthForProvider?: XaiToolAuthContext["hasAuthForProvider"]; - resolveApiKeyForProvider?: XaiToolAuthContext["resolveApiKeyForProvider"]; -}) { +function shouldExposeXaiBilledTool(params: { + activeProvider?: string; + enabled?: unknown; +}): boolean { + const activeProvider = params.activeProvider?.trim(); + if (!activeProvider || params.enabled === false) { + return false; + } + // Cross-provider billing requires explicit consent; xAI models retain the + // credential-backed default. Unknown providers fail closed. + return activeProvider === PROVIDER_ID || params.enabled === true; +} + +function createLazyCodeExecutionTool(ctx: OpenClawPluginToolContext) { const effectiveConfig = ctx.runtimeConfig ?? ctx.config; + const codeExecutionConfig = readPluginCodeExecutionConfig(effectiveConfig); + if ( + !shouldExposeXaiBilledTool({ + activeProvider: ctx.activeModel?.provider, + enabled: codeExecutionConfig?.enabled, + }) + ) { + return null; + } if (!isCodeExecutionEnabled(effectiveConfig, ctx)) { return null; } @@ -119,13 +137,17 @@ function createLazyCodeExecutionTool(ctx: { ); } -function createLazyXSearchTool(ctx: { - config?: Record; - runtimeConfig?: Record; - hasAuthForProvider?: XaiToolAuthContext["hasAuthForProvider"]; - resolveApiKeyForProvider?: XaiToolAuthContext["resolveApiKeyForProvider"]; -}) { +function createLazyXSearchTool(ctx: OpenClawPluginToolContext) { const effectiveConfig = ctx.runtimeConfig ?? ctx.config; + const xSearchConfig = resolveEffectiveXSearchConfig(effectiveConfig); + if ( + !shouldExposeXaiBilledTool({ + activeProvider: ctx.activeModel?.provider, + enabled: xSearchConfig?.enabled, + }) + ) { + return null; + } if (!isXSearchEnabled(effectiveConfig, ctx)) { return null; } diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index a1d05edaf42a..164f1735dfaf 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -140,11 +140,11 @@ }, "codeExecution.enabled": { "label": "Enable Code Execution", - "help": "Enable the code_execution tool for remote xAI sandbox analysis." + "help": "Expose code_execution on active xAI models; true opts in when the active provider is known non-xAI, false disables, and missing provider fails closed. Requires xAI auth; xAI bills $5/1,000 calls plus model tokens." }, "xSearch.enabled": { "label": "Enable X Search", - "help": "Enable the x_search tool for searching X posts with xAI." + "help": "Expose x_search on active xAI models; true opts in when the active provider is known non-xAI, false disables, and missing provider fails closed. Requires xAI auth; xAI bills $5/1,000 calls plus model tokens." }, "xSearch.model": { "label": "X Search Model", diff --git a/extensions/xai/xai.live.test.ts b/extensions/xai/xai.live.test.ts index 5a6944c9052e..62955f0cccf2 100644 --- a/extensions/xai/xai.live.test.ts +++ b/extensions/xai/xai.live.test.ts @@ -4,6 +4,8 @@ import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime"; +import type { OpenClawPluginToolFactory } from "openclaw/plugin-sdk/plugin-entry"; +import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { registerProviderPlugin, requireRegisteredProvider, @@ -96,6 +98,20 @@ const registerXaiPlugin = () => name: "xAI Provider", }); +function registerXaiToolFactories(): Map { + const factories = new Map(); + plugin.register( + createTestPluginApi({ + registerTool(tool, options) { + if (typeof tool === "function" && options?.name) { + factories.set(options.name, tool); + } + }, + }), + ); + return factories; +} + async function runXaiLiveCase(label: string, run: () => Promise): Promise { try { await run(); @@ -114,6 +130,76 @@ function isRealtimeOpenBillingDrift(error: Error): boolean { } describeLive("xai plugin live", () => { + it("gates registered billed tools and honors explicit cross-provider consent", async () => { + await runXaiLiveCase("billed-tool-policy", async () => { + const codeExecutionFactory = registerXaiToolFactories().get("code_execution"); + if (!codeExecutionFactory) { + throw new Error("expected code_execution factory to be registered"); + } + const baseConfig = { + plugins: { + entries: { + xai: { + config: { + webSearch: { apiKey: XAI_API_KEY }, + }, + }, + }, + }, + } as OpenClawConfig; + const explicitConfig = { + plugins: { + entries: { + xai: { + config: { + webSearch: { apiKey: XAI_API_KEY }, + codeExecution: { enabled: true, maxTurns: 1, timeoutSeconds: 90 }, + }, + }, + }, + }, + } as OpenClawConfig; + + expect( + codeExecutionFactory({ + config: baseConfig, + activeModel: { provider: "xai", modelId: "grok-4.3" }, + }), + ).not.toBeNull(); + expect( + codeExecutionFactory({ + config: baseConfig, + activeModel: { provider: "openai", modelId: "gpt-5.4" }, + }), + ).toBeNull(); + expect( + codeExecutionFactory({ + config: explicitConfig, + }), + ).toBeNull(); + + const explicitCrossProviderTool = codeExecutionFactory({ + config: explicitConfig, + activeModel: { provider: "openai", modelId: "gpt-5.4" }, + }); + if (!explicitCrossProviderTool || Array.isArray(explicitCrossProviderTool)) { + throw new Error("expected explicit cross-provider code_execution tool"); + } + const result = await explicitCrossProviderTool.execute("code-execution:cross-provider-live", { + task: "Use the code interpreter to calculate 6 multiplied by 7.", + }); + const details = (result.details ?? {}) as { + content?: string; + model?: string; + usedCodeExecution?: boolean; + }; + + expect(details.model).toBe("grok-4.3"); + expect(details.usedCodeExecution).toBe(true); + expect(details.content).toContain("42"); + }); + }, 120_000); + it("runs remote code execution with the current default model", async () => { await runXaiLiveCase("code-execution", async () => { const tool = createCodeExecutionTool({