Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
// Xai type declarations define plugin contracts.
export const resolveAnthropicVertexClientRegion: (...args: unknown[]) => unknown;
export const resolveAnthropicVertexProjectId: (...args: unknown[]) => unknown;

View File

@@ -0,0 +1,2 @@
// Xai type declarations define plugin contracts.
export const resolveOllamaApiBase: (...args: unknown[]) => unknown;

View File

@@ -0,0 +1,17 @@
// Xai type declarations define plugin contracts.
export type OllamaEmbeddingClient = unknown;
export const buildAssistantMessage: (...args: unknown[]) => unknown;
export const buildOllamaChatRequest: (...args: unknown[]) => unknown;
export const convertToOllamaMessages: (...args: unknown[]) => unknown;
export const createConfiguredOllamaCompatNumCtxWrapper: (...args: unknown[]) => unknown;
export const createConfiguredOllamaCompatStreamWrapper: (...args: unknown[]) => unknown;
export const createConfiguredOllamaStreamFn: (...args: unknown[]) => unknown;
export const createOllamaStreamFn: (...args: unknown[]) => unknown;
export const createOllamaEmbeddingProvider: (...args: unknown[]) => unknown;
export const isOllamaCompatProvider: (...args: unknown[]) => unknown;
export const resolveOllamaBaseUrlForRun: (...args: unknown[]) => unknown;
export const resolveOllamaCompatNumCtxEnabled: (...args: unknown[]) => unknown;
export const shouldInjectOllamaCompatNumCtx: (...args: unknown[]) => unknown;
export const parseNdjsonStream: (...args: unknown[]) => unknown;
export const wrapOllamaCompatNumCtx: (...args: unknown[]) => unknown;

View File

@@ -0,0 +1,34 @@
// Xai type declarations define plugin contracts.
export type ResolvedTtsConfig = unknown;
export type ResolvedTtsModelOverrides = unknown;
export type TtsDirectiveOverrides = unknown;
export type TtsDirectiveParseResult = unknown;
export type TtsResult = unknown;
export type TtsSynthesisResult = unknown;
export type TtsTelephonyResult = unknown;
export const testApi: unknown;
export { testApi as _test };
export const buildTtsSystemPromptHint: (...args: unknown[]) => unknown;
export const getLastTtsAttempt: (...args: unknown[]) => unknown;
export const getResolvedSpeechProviderConfig: (...args: unknown[]) => unknown;
export const getTtsMaxLength: (...args: unknown[]) => unknown;
export const getTtsProvider: (...args: unknown[]) => unknown;
export const isSummarizationEnabled: (...args: unknown[]) => unknown;
export const isTtsEnabled: (...args: unknown[]) => unknown;
export const isTtsProviderConfigured: (...args: unknown[]) => unknown;
export const listSpeechVoices: (...args: unknown[]) => unknown;
export const maybeApplyTtsToPayload: (...args: unknown[]) => unknown;
export const resolveTtsAutoMode: (...args: unknown[]) => unknown;
export const resolveTtsConfig: (...args: unknown[]) => unknown;
export const resolveTtsPrefsPath: (...args: unknown[]) => unknown;
export const resolveTtsProviderOrder: (...args: unknown[]) => unknown;
export const setLastTtsAttempt: (...args: unknown[]) => unknown;
export const setSummarizationEnabled: (...args: unknown[]) => unknown;
export const setTtsAutoMode: (...args: unknown[]) => unknown;
export const setTtsEnabled: (...args: unknown[]) => unknown;
export const setTtsMaxLength: (...args: unknown[]) => unknown;
export const setTtsProvider: (...args: unknown[]) => unknown;
export const synthesizeSpeech: (...args: unknown[]) => unknown;
export const textToSpeech: (...args: unknown[]) => unknown;
export const textToSpeechTelephony: (...args: unknown[]) => unknown;

View File

@@ -0,0 +1,34 @@
// Xai tests cover api plugin behavior.
import { describe, expect, it } from "vitest";
import { isXaiModelHint, resolveXaiTransport } from "./api.js";
describe("xai api helpers", () => {
it("uses shared endpoint classification for native xAI transports", () => {
expect(
resolveXaiTransport({
provider: "custom-xai",
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
}),
).toEqual({
api: "openai-responses",
baseUrl: "https://api.x.ai/v1",
});
});
it("keeps default-route xAI transport for the declared provider", () => {
expect(
resolveXaiTransport({
provider: "xai",
api: "openai-completions",
}),
).toEqual({
api: "openai-responses",
baseUrl: undefined,
});
});
it("detects xAI model hints", () => {
expect(isXaiModelHint("x-ai/grok-4")).toBe(true);
});
});

107
extensions/xai/api.ts Normal file
View File

@@ -0,0 +1,107 @@
// Xai API module exposes the plugin public contract.
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
normalizeOptionalLowercaseString,
readStringValue,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
applyXaiModelCompat,
HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING,
normalizeNativeXaiModelId,
XAI_TOOL_SCHEMA_PROFILE,
} from "./model-compat.js";
export { buildXaiProvider } from "./provider-catalog.js";
export { applyXaiConfig, applyXaiProviderConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js";
export { buildXaiImageGenerationProvider } from "./image-generation-provider.js";
export {
buildXaiCatalogModels,
buildXaiModelDefinition,
resolveXaiCatalogEntry,
XAI_BASE_URL,
XAI_DEFAULT_CONTEXT_WINDOW,
XAI_DEFAULT_IMAGE_MODEL,
XAI_DEFAULT_MODEL_ID,
XAI_DEFAULT_MAX_TOKENS,
XAI_IMAGE_MODELS,
} from "./model-definitions.js";
export { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
export { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js";
export { applyXaiModelCompat, HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, XAI_TOOL_SCHEMA_PROFILE };
const XAI_NATIVE_ENDPOINT_HOSTS = new Set(["api.x.ai"]);
function resolveHostname(value: string): string | undefined {
try {
return new URL(value).hostname.toLowerCase();
} catch {
return undefined;
}
}
function isXaiNativeEndpoint(baseUrl: unknown): boolean {
return (
typeof baseUrl === "string" && XAI_NATIVE_ENDPOINT_HOSTS.has(resolveHostname(baseUrl) ?? "")
);
}
export function isXaiModelHint(modelId: string): boolean {
return getModelProviderHint(modelId) === "x-ai";
}
export { normalizeNativeXaiModelId as normalizeXaiModelId };
function getModelProviderHint(modelId: string): string | null {
const trimmed = normalizeOptionalLowercaseString(modelId);
if (!trimmed) {
return null;
}
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0) {
return null;
}
return trimmed.slice(0, slashIndex) || null;
}
function shouldUseXaiResponsesTransport(params: {
provider: string;
api?: unknown;
baseUrl?: unknown;
}): boolean {
if (params.api !== "openai-completions") {
return false;
}
if (isXaiNativeEndpoint(params.baseUrl)) {
return true;
}
return normalizeProviderId(params.provider) === "xai" && !params.baseUrl;
}
export function resolveXaiTransport(params: {
provider: string;
api?: unknown;
baseUrl?: unknown;
}): { api: "openai-responses"; baseUrl?: string } | undefined {
if (!shouldUseXaiResponsesTransport(params)) {
return undefined;
}
return {
api: "openai-responses",
baseUrl: readStringValue(params.baseUrl),
};
}
export function resolveXaiBaseUrl(baseUrlOrConfig?: unknown): string {
let candidate = baseUrlOrConfig;
if (
baseUrlOrConfig &&
typeof baseUrlOrConfig === "object" &&
!Array.isArray(baseUrlOrConfig) &&
"cfg" in baseUrlOrConfig
) {
candidate =
(baseUrlOrConfig as { cfg?: { models?: { providers?: { xai?: { baseUrl?: unknown } } } } })
.cfg?.models?.providers?.xai?.baseUrl ?? baseUrlOrConfig;
}
return readStringValue(candidate) || "https://api.x.ai/v1";
}

View File

@@ -0,0 +1,30 @@
// Xai plugin module implements code execution tool shared behavior.
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import { Type } from "typebox";
export function buildMissingCodeExecutionApiKeyPayload() {
return {
error: "missing_xai_api_key",
message:
"code_execution needs xAI credentials. Run `openclaw onboard --auth-choice xai-oauth` to sign in with Grok, run `openclaw onboard --auth-choice 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/code-execution",
};
}
export function createCodeExecutionToolDefinition(
execute: (toolCallId: string, args: Record<string, unknown>) => Promise<AgentToolResult<unknown>>,
) {
return {
label: "Code Execution",
name: "code_execution",
description:
"Run sandboxed Python analysis with xAI. Use for calculations, tabulation, summaries, and chart-style analysis without local machine access.",
parameters: Type.Object({
task: Type.String({
description:
"The full analysis task for xAI's remote Python sandbox. Include any data to analyze directly in the task.",
}),
}),
execute,
};
}

View File

@@ -0,0 +1,270 @@
// Xai tests cover code execution plugin behavior.
import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCodeExecutionTool } from "./code-execution.js";
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function malformedJsonResponse(): Response {
return new Response("{ nope", {
status: 200,
headers: { "content-type": "application/json" },
});
}
function installCodeExecutionFetch(payload?: Record<string, unknown>) {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(
jsonResponse(
payload ?? {
output: [
{ type: "code_interpreter_call" },
{
type: "message",
content: [
{
type: "output_text",
text: "Mean: 42",
annotations: [{ type: "url_citation", url: "https://example.com/data.csv" }],
},
],
},
],
citations: ["https://example.com/data.csv"],
},
),
),
);
global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
}
function firstFetchCall(mockFetch: ReturnType<typeof installCodeExecutionFetch>) {
const [call] = mockFetch.mock.calls;
if (!call) {
throw new Error("expected code_execution fetch call");
}
return call;
}
function firstFetchUrl(mockFetch: ReturnType<typeof installCodeExecutionFetch>) {
const [url] = firstFetchCall(mockFetch);
return String(url);
}
function firstFetchInit(mockFetch: ReturnType<typeof installCodeExecutionFetch>): RequestInit {
const [, init] = firstFetchCall(mockFetch);
if (!init || typeof init !== "object" || Array.isArray(init)) {
throw new Error("expected code_execution fetch init");
}
return init as RequestInit;
}
function firstAuthorizationHeader(mockFetch: ReturnType<typeof installCodeExecutionFetch>) {
const headers = firstFetchInit(mockFetch).headers;
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
throw new Error("expected code_execution request headers");
}
return (headers as Record<string, string>).Authorization;
}
function parseFirstRequestBody(mockFetch: ReturnType<typeof installCodeExecutionFetch>) {
const requestBody = firstFetchInit(mockFetch).body;
return JSON.parse(typeof requestBody === "string" ? requestBody : "{}") as Record<
string,
unknown
>;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("xai code_execution tool", () => {
it("enables code_execution when the xAI plugin web search key is configured", () => {
const tool = createCodeExecutionTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
},
},
},
},
},
},
});
expect(tool?.name).toBe("code_execution");
});
it("enables code_execution from an xAI auth profile and uses it for requests", async () => {
const mockFetch = installCodeExecutionFetch();
const tool = createCodeExecutionTool({
config: {},
auth: {
hasAuthForProvider: (providerId) => providerId === "xai",
resolveApiKeyForProvider: async (providerId) =>
providerId === "xai" ? "xai-profile-key" : undefined, // pragma: allowlist secret
},
});
expect(tool?.name).toBe("code_execution");
await tool?.execute?.("code-execution:auth-profile", {
task: "Sum [20, 22]",
});
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer xai-profile-key");
});
it("uses the xAI Responses code_interpreter tool", async () => {
const mockFetch = installCodeExecutionFetch();
const tool = createCodeExecutionTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-config-test", // pragma: allowlist secret
},
codeExecution: {
model: "grok-4-1-fast",
maxTurns: 2,
timeoutSeconds: 45,
},
},
},
},
},
},
});
const result = await tool?.execute?.("code-execution:1", {
task: "Calculate the mean of [40, 42, 44]",
});
expect(mockFetch).toHaveBeenCalled();
expect(firstFetchUrl(mockFetch)).toContain("api.x.ai/v1/responses");
const body = parseFirstRequestBody(mockFetch);
expect(body.model).toBe("grok-4-1-fast");
expect(body.max_turns).toBe(2);
expect(body.tools).toEqual([{ type: "code_interpreter" }]);
expect(
(result?.details as { usedCodeExecution?: boolean } | undefined)?.usedCodeExecution,
).toBe(true);
});
it("reuses the xAI plugin web search key for code_execution requests", async () => {
const mockFetch = installCodeExecutionFetch();
const tool = createCodeExecutionTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
},
},
},
},
},
},
});
await tool?.execute?.("code-execution:plugin-key", {
task: "Compute the standard deviation of [1, 2, 3]",
});
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer xai-plugin-key");
});
it("reports malformed code_execution JSON as a provider error", async () => {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(malformedJsonResponse()),
);
global.fetch = withFetchPreconnect(mockFetch);
const tool = createCodeExecutionTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
},
},
},
},
},
},
});
await expect(
tool?.execute?.("code-execution:malformed-json", {
task: "Calculate the mean of [40, 42, 44]",
}),
).rejects.toThrow("xAI code execution failed: malformed JSON response");
});
it("rejects code_execution success JSON without answer text", async () => {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(jsonResponse({ output: [{ type: "code_interpreter_call" }] })),
);
global.fetch = withFetchPreconnect(mockFetch);
const tool = createCodeExecutionTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
},
},
},
},
},
},
});
await expect(
tool?.execute?.("code-execution:missing-text", {
task: "Calculate the mean of [40, 42, 44]",
}),
).rejects.toThrow("xAI code execution failed: malformed JSON response");
});
it("reuses the legacy grok web search key for code_execution requests", async () => {
const mockFetch = installCodeExecutionFetch();
const tool = createCodeExecutionTool({
config: {
tools: {
web: {
search: {
grok: {
apiKey: "xai-legacy-key", // pragma: allowlist secret
},
},
},
},
},
});
await tool?.execute?.("code-execution:legacy-key", {
task: "Count rows in a two-column table",
});
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer xai-legacy-key");
});
});

View File

@@ -0,0 +1,82 @@
// Xai plugin module implements code execution behavior.
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/provider-web-search";
import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
import {
buildMissingCodeExecutionApiKeyPayload,
createCodeExecutionToolDefinition,
} from "./code-execution-tool-shared.js";
import {
readCodeExecutionConfigRecord,
readPluginCodeExecutionConfig,
resolveCodeExecutionEnabled,
} from "./src/code-execution-config.js";
import {
buildXaiCodeExecutionPayload,
requestXaiCodeExecution,
resolveXaiCodeExecutionMaxTurns,
resolveXaiCodeExecutionModel,
} from "./src/code-execution-shared.js";
import { resolveXaiToolApiKeyWithAuth, type XaiToolAuthContext } from "./src/tool-auth-shared.js";
export function createCodeExecutionTool(options?: {
config?: unknown;
runtimeConfig?: Record<string, unknown> | null;
auth?: XaiToolAuthContext;
}) {
const runtimeConfig = options?.runtimeConfig ?? getRuntimeConfigSnapshot();
const codeExecutionConfig =
readPluginCodeExecutionConfig(runtimeConfig ?? undefined) ??
readPluginCodeExecutionConfig(options?.config);
if (
!resolveCodeExecutionEnabled({
sourceConfig: options?.config,
runtimeConfig: runtimeConfig ?? undefined,
config: codeExecutionConfig,
auth: options?.auth,
})
) {
return null;
}
return createCodeExecutionToolDefinition(
async (_toolCallId: string, args: Record<string, unknown>) => {
const apiKey = await resolveXaiToolApiKeyWithAuth({
runtimeConfig: (runtimeConfig ?? undefined) as never,
sourceConfig: options?.config as never,
auth: options?.auth,
});
if (!apiKey) {
return jsonResult(buildMissingCodeExecutionApiKeyPayload());
}
const task = readStringParam(args, "task", { required: true });
const codeExecutionConfigRecord = readCodeExecutionConfigRecord(codeExecutionConfig);
const model = resolveXaiCodeExecutionModel(codeExecutionConfigRecord);
const maxTurns = resolveXaiCodeExecutionMaxTurns(codeExecutionConfigRecord);
const timeoutSeconds =
typeof codeExecutionConfigRecord?.timeoutSeconds === "number" &&
Number.isFinite(codeExecutionConfigRecord.timeoutSeconds)
? codeExecutionConfigRecord.timeoutSeconds
: 30;
const startedAt = Date.now();
const result = await requestXaiCodeExecution({
apiKey,
model,
timeoutSeconds,
maxTurns,
task,
});
return jsonResult(
buildXaiCodeExecutionPayload({
task,
model,
tookMs: Date.now() - startedAt,
content: result.content,
citations: result.citations,
usedCodeExecution: result.usedCodeExecution,
outputTypes: result.outputTypes,
}),
);
},
);
}

View File

@@ -0,0 +1,299 @@
// Xai tests cover image generation provider plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildXaiImageGenerationProvider } from "./image-generation-provider.js";
const {
resolveApiKeyForProviderMock,
isProviderApiKeyConfiguredMock,
postJsonRequestMock,
postMultipartRequestMock,
assertOkOrThrowHttpErrorMock,
resolveProviderHttpRequestConfigMock,
createProviderOperationDeadlineMock,
resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequestMock,
} = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "xai-key" })),
isProviderApiKeyConfiguredMock: vi.fn(() => true),
postJsonRequestMock: vi.fn(),
postMultipartRequestMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => {
const headers = new Headers(params.defaultHeaders as HeadersInit | undefined);
// Stub mirroring the xAI attribution policy headers (real wire is locked in provider-attribution.test.ts).
if (params.provider === "xai") {
const version = process.env.OPENCLAW_VERSION?.trim() || "unknown";
headers.set("User-Agent", `openclaw/${version}`);
headers.set("originator", "openclaw");
headers.set("version", version);
}
return {
baseUrl: params.baseUrl ?? params.defaultBaseUrl ?? "https://api.x.ai/v1",
allowPrivateNetwork: false,
headers,
dispatcherPolicy: undefined,
};
}),
createProviderOperationDeadlineMock: vi.fn((params: Record<string, unknown>) => ({
timeoutMs: params.timeoutMs,
label: params.label,
})),
resolveProviderOperationTimeoutMsMock: vi.fn(
(params: Record<string, unknown>) => params.defaultTimeoutMs ?? 60000,
),
sanitizeConfiguredModelProviderRequestMock: vi.fn((request) => request),
}));
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
isProviderApiKeyConfigured: isProviderApiKeyConfiguredMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
"openclaw/plugin-sdk/provider-http",
);
return {
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
readProviderJsonResponse: actual.readProviderJsonResponse,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
};
});
vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => ({
normalizeOptionalString: (v: unknown) => (typeof v === "string" ? v.trim() : undefined),
normalizeOptionalLowercaseString: (v: unknown) =>
typeof v === "string" ? v.trim().toLowerCase() : undefined,
readStringValue: (v: unknown) => (typeof v === "string" ? v.trim() : undefined),
}));
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function requirePostJsonCall(index = 0): {
url?: string;
timeoutMs?: number;
body?: Record<string, unknown>;
headers?: Headers;
} {
const params = (postJsonRequestMock.mock.calls as unknown as Array<[unknown]>)[index]?.[0] as
| {
url?: string;
timeoutMs?: number;
body?: Record<string, unknown>;
headers?: Headers;
}
| undefined;
if (!params) {
throw new Error(`Expected postJsonRequest call ${index}`);
}
return params;
}
describe("xai image generation provider", () => {
afterEach(() => {
resolveApiKeyForProviderMock.mockClear();
isProviderApiKeyConfiguredMock.mockClear();
postJsonRequestMock.mockReset();
assertOkOrThrowHttpErrorMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
createProviderOperationDeadlineMock.mockClear();
resolveProviderOperationTimeoutMsMock.mockClear();
sanitizeConfiguredModelProviderRequestMock.mockClear();
});
it("builds provider with correct models, default, and capabilities", () => {
const provider = buildXaiImageGenerationProvider();
expect(provider.id).toBe("xai");
expect(provider.label).toBe("xAI");
expect(provider.defaultModel).toBe("grok-imagine-image");
expect(provider.models).toEqual(["grok-imagine-image", "grok-imagine-image-quality"]);
expect(provider.capabilities.generate.maxCount).toBe(4);
expect(provider.capabilities.generate.supportsAspectRatio).toBe(true);
expect(provider.capabilities.geometry?.aspectRatios).toEqual([
"1:1",
"16:9",
"9:16",
"4:3",
"3:4",
"2:3",
"3:2",
]);
expect(provider.capabilities.edit.enabled).toBe(true);
expect(provider.capabilities.edit.maxInputImages).toBe(5);
const isConfigured = provider.isConfigured;
if (!isConfigured) {
throw new Error("expected XAI image provider config predicate");
}
expect(isConfigured({ agentDir: "/tmp/openclaw-xai-test" })).toBe(true);
expect(isProviderApiKeyConfiguredMock).toHaveBeenCalledWith({
provider: "xai",
agentDir: "/tmp/openclaw-xai-test",
});
});
it("uses main provider URL and resolves auth for generation", async () => {
postJsonRequestMock.mockResolvedValue({
response: jsonResponse({
data: [{ b64_json: Buffer.from("testpng").toString("base64") }],
}),
release: vi.fn(async () => {}),
});
const provider = buildXaiImageGenerationProvider();
await provider.generateImage({
provider: "xai",
model: "grok-imagine-image",
prompt: "test prompt",
aspectRatio: "2:3",
resolution: "2K",
cfg: {
models: {
providers: {
xai: {
baseUrl: "https://custom.x.ai/v1",
},
},
},
},
} as any);
const authParams = (
resolveApiKeyForProviderMock.mock.calls as unknown as Array<[unknown]>
)[0]?.[0] as { provider?: string } | undefined;
expect(authParams?.provider).toBe("xai");
const httpParams = (
resolveProviderHttpRequestConfigMock.mock.calls as unknown as Array<[unknown]>
)[0]?.[0] as
| {
provider?: string;
capability?: string;
baseUrl?: string;
}
| undefined;
expect(httpParams?.provider).toBe("xai");
expect(httpParams?.capability).toBe("image");
expect(httpParams?.baseUrl).toBe("https://custom.x.ai/v1");
const request = requirePostJsonCall();
expect(request.url).toContain("/images/generations");
expect(provider.defaultTimeoutMs).toBe(600_000);
expect(request.timeoutMs).toBe(600_000);
expect(request.body?.aspect_ratio).toBe("2:3");
expect(request.body?.resolution).toBe("2k");
expect(resolveProviderOperationTimeoutMsMock).toHaveBeenCalledWith(
expect.objectContaining({
defaultTimeoutMs: 600_000,
}),
);
});
it("supports edit with exact user-provided payload format including image object with type image_url", async () => {
postJsonRequestMock.mockResolvedValue({
response: jsonResponse({
data: [
{
b64_json:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4z0ABAAEfAG0B0xMAAAAASUVORK5CYII=",
mime_type: "image/png",
},
],
}),
release: vi.fn(async () => {}),
});
const provider = buildXaiImageGenerationProvider();
const buffer = Buffer.from("fakeimage");
await provider.generateImage({
provider: "xai",
model: "grok-imagine-image-quality",
prompt: "Render this as a pencil sketch with detailed shading",
inputImages: [
{
buffer,
mimeType: "image/png",
},
],
cfg: {},
} as any);
const request = requirePostJsonCall();
expect(request.url).toContain("/images/edits");
expect(request.body?.model).toBe("grok-imagine-image-quality");
expect(request.body?.prompt).toBe("Render this as a pencil sketch with detailed shading");
const image = request.body?.image as { url?: string; type?: string } | undefined;
expect(image?.url).toContain("data:image/png;base64,");
expect(image?.type).toBe("image_url");
expect(request.body?.response_format).toBe("b64_json");
});
it("forwards xAI attribution User-Agent through the SDK image request", async () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
postJsonRequestMock.mockResolvedValue({
response: jsonResponse({
data: [{ b64_json: Buffer.from("ua-png").toString("base64") }],
}),
release: vi.fn(async () => {}),
});
const provider = buildXaiImageGenerationProvider();
await provider.generateImage({
provider: "xai",
model: "grok-imagine-image",
prompt: "ua check",
cfg: {},
} as any);
const request = requirePostJsonCall();
expect(request.headers?.get("user-agent")).toBe("openclaw/2026.3.22");
expect(request.headers?.get("originator")).toBe("openclaw");
expect(request.headers?.get("version")).toBe("2026.3.22");
vi.unstubAllEnvs();
});
it("uses the plural xAI images payload for multiple edit inputs", async () => {
postJsonRequestMock.mockResolvedValue({
response: jsonResponse({
data: [
{
b64_json: Buffer.from("edited").toString("base64"),
mime_type: "image/png",
},
],
}),
release: vi.fn(async () => {}),
});
const provider = buildXaiImageGenerationProvider();
await provider.generateImage({
provider: "xai",
model: "grok-imagine-image",
prompt: "Combine the references",
inputImages: [
{ buffer: Buffer.from("first"), mimeType: "image/png" },
{ buffer: Buffer.from("second"), mimeType: "image/jpeg" },
],
cfg: {},
} as any);
const request = requirePostJsonCall();
expect(request.url).toContain("/images/edits");
const images = request.body?.images as Array<{ url?: string; type?: string }> | undefined;
expect(images).toHaveLength(2);
expect(images?.[0]?.url).toContain("data:image/png;base64,");
expect(images?.[0]?.type).toBe("image_url");
expect(images?.[1]?.url).toContain("data:image/jpeg;base64,");
expect(images?.[1]?.type).toBe("image_url");
});
});

View File

@@ -0,0 +1,125 @@
// Xai provider module implements model/runtime integration.
import type {
ImageGenerationProvider,
ImageGenerationRequest,
ImageGenerationSourceImage,
} from "openclaw/plugin-sdk/image-generation";
import {
createOpenAiCompatibleImageGenerationProvider,
toImageDataUrl,
} from "openclaw/plugin-sdk/image-generation";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { XAI_BASE_URL, XAI_DEFAULT_IMAGE_MODEL, XAI_IMAGE_MODELS } from "./model-definitions.js";
const DEFAULT_TIMEOUT_MS = 600_000;
const XAI_SUPPORTED_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "2:3", "3:2"] as const;
function resolveImageForEdit(
input: (ImageGenerationSourceImage & { url?: string }) | undefined,
): string {
if (!input) {
throw new Error("xAI image edit requires an input image.");
}
const url = normalizeOptionalString(input.url);
if (url) {
return url;
}
if (!input.buffer) {
throw new Error("xAI image edit input is missing both URL and buffer data.");
}
return toImageDataUrl({ buffer: input.buffer, mimeType: input.mimeType });
}
function resolveXaiImageBaseUrl(req: ImageGenerationRequest): string {
return normalizeOptionalString(req.cfg?.models?.providers?.xai?.baseUrl) ?? XAI_BASE_URL;
}
function buildBody(params: {
req: ImageGenerationRequest;
inputImages: ImageGenerationSourceImage[];
model: string;
count: number;
}): Record<string, unknown> {
const body: Record<string, unknown> = {
model: params.model,
prompt: params.req.prompt,
n: Math.min(params.count, 4),
response_format: "b64_json" as const,
};
const aspect = normalizeOptionalString(params.req.aspectRatio);
if (aspect && (XAI_SUPPORTED_ASPECT_RATIOS as readonly string[]).includes(aspect)) {
body.aspect_ratio = aspect;
}
const resolution = normalizeOptionalLowercaseString(params.req.resolution);
if (resolution) {
body.resolution = resolution;
}
if (params.inputImages.length > 0) {
if (params.inputImages.length > 1) {
body.images = params.inputImages.map((input) => ({
url: resolveImageForEdit(input),
type: "image_url",
}));
} else {
body.image = {
url: resolveImageForEdit(params.inputImages[0]),
type: "image_url",
};
}
}
return body;
}
export function buildXaiImageGenerationProvider(): ImageGenerationProvider {
return createOpenAiCompatibleImageGenerationProvider({
id: "xai",
label: "xAI",
defaultModel: XAI_DEFAULT_IMAGE_MODEL,
models: [...XAI_IMAGE_MODELS],
capabilities: {
generate: {
maxCount: 4,
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: false,
},
edit: {
enabled: true,
maxCount: 4,
maxInputImages: 5,
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: false,
},
geometry: {
aspectRatios: [...XAI_SUPPORTED_ASPECT_RATIOS],
resolutions: ["1K", "2K"],
},
},
defaultBaseUrl: XAI_BASE_URL,
resolveBaseUrl: ({ req }) => resolveXaiImageBaseUrl(req),
resolveAllowPrivateNetwork: () => false,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
buildGenerateRequest: ({ req, inputImages, model, count }) => ({
kind: "json",
body: buildBody({ req, inputImages, model, count }),
}),
buildEditRequest: ({ req, inputImages, model, count }) => ({
kind: "json",
body: buildBody({ req, inputImages, model, count }),
}),
missingApiKeyError: "xAI API key missing",
failureLabels: {
generate: "xAI image generation failed",
edit: "xAI image edit failed",
},
});
}

View File

@@ -0,0 +1,612 @@
// Xai tests cover index plugin behavior.
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import {
registerProviderPlugin,
registerSingleProviderPlugin,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import {
clearLiveCatalogCacheForTests,
type LiveModelCatalogFetchGuard,
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const providerAuthRuntimeMocks = vi.hoisted(() => ({
resolveApiKeyForProvider: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => providerAuthRuntimeMocks);
import plugin from "./index.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import { buildLiveXaiProvider } from "./provider-catalog.js";
import setupPlugin from "./setup-api.js";
import {
createXaiPayloadCaptureStream,
expectXaiFastToolStreamShaping,
runXaiGrok4ResponseStream,
} from "./test-helpers.js";
function createProviderModel(overrides: {
id: string;
api?: string;
baseUrl?: string;
provider?: string;
}) {
return {
id: overrides.id,
name: overrides.id,
api: overrides.api ?? "openai-completions",
provider: overrides.provider ?? "xai",
baseUrl: overrides.baseUrl ?? "https://api.x.ai/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
};
}
type XaiAutoEnableProbe = Parameters<OpenClawPluginApi["registerAutoEnableProbe"]>[0];
function registerXaiAutoEnableProbe(): XaiAutoEnableProbe {
const probes: XaiAutoEnableProbe[] = [];
setupPlugin.register(
createTestPluginApi({
registerAutoEnableProbe(probe) {
probes.push(probe);
},
}),
);
const probe = probes[0];
if (!probe) {
throw new Error("expected xAI setup plugin to register an auto-enable probe");
}
return probe;
}
function requireEntry<T extends { id?: string }>(entries: T[], id: string): T {
const entry = entries.find((candidate) => candidate.id === id);
if (!entry) {
throw new Error(`Expected entry ${id}`);
}
return entry;
}
describe("xai provider plugin", () => {
beforeEach(() => {
clearLiveCatalogCacheForTests();
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("exposes xAI OAuth and preserves the explicit device-code alias", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(provider.auth?.map((method) => method.id)).toEqual(["api-key", "oauth", "device-code"]);
const oauth = provider.auth?.find((method) => method.id === "oauth");
expect(oauth?.kind).toBe("oauth");
expect(oauth?.wizard?.choiceId).toBe("xai-oauth");
const deviceCode = provider.auth?.find((method) => method.id === "device-code");
expect(deviceCode?.kind).toBe("device_code");
expect(deviceCode?.wizard?.choiceId).toBe("xai-device-code");
expect(deviceCode?.wizard?.assistantVisibility).toBe("manual-only");
expect(manifest.providerAuthChoices).toContainEqual(
expect.objectContaining({
assistantVisibility: "manual-only",
choiceId: "xai-device-code",
method: "device-code",
}),
);
});
it("filters the xAI API-key catalog against live model ids", async () => {
const release = vi.fn(async () => undefined);
const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async () => ({
response: Response.json({
data: [
{ id: "grok-4.3", object: "model" },
{ id: "not-in-manifest", object: "model" },
],
}),
finalUrl: "https://api.x.ai/v1/models",
release,
}));
const provider = await buildLiveXaiProvider({
apiKey: "xai-key",
fetchGuard,
});
expect(provider.apiKey).toBe("xai-key");
expect(provider.models.map((model) => model.id)).toContain("grok-4.3");
expect(provider.models.map((model) => model.id)).not.toContain("not-in-manifest");
const fetchParams = vi.mocked(fetchGuard).mock.calls[0]?.[0];
expect(fetchParams?.url).toBe("https://api.x.ai/v1/models");
const init = fetchParams?.init;
const headers = init?.headers;
expect(headers).toBeInstanceOf(Headers);
if (!(headers instanceof Headers)) {
throw new Error("expected fetch headers");
}
expect(headers.get("Authorization")).toBe("Bearer xai-key");
expect(release).toHaveBeenCalledOnce();
});
it("uses the Grok OAuth proxy catalog for xAI OAuth discovery", async () => {
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue({
apiKey: "xai-oauth-token",
mode: "oauth",
source: "profile:xai-profile",
profileId: "xai-profile",
});
const fetchMock = vi.fn(async () =>
Response.json({
data: [
{
id: "grok-composer-2.5-fast",
model: "grok-composer-2.5-fast",
name: "Composer 2.5",
api_backend: "responses",
context_window: 200_000,
},
{
id: "grok-build",
model: "grok-build",
name: "Grok Build",
api_backend: "responses",
context_window: 512_000,
},
{
id: "grok-imagine-image",
model: "grok-imagine-image",
name: "Grok Imagine",
api_backend: "image",
},
],
}),
);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const provider = await registerSingleProviderPlugin(plugin);
const result = await provider.catalog?.run({
config: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
env: {},
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: "stale-oauth-token",
mode: "oauth",
source: "profile",
profileId: "xai-profile",
}),
resolveProviderApiKey: () => ({
apiKey: "env-xai-key",
discoveryApiKey: "env-xai-key",
}),
});
if (!result || !("provider" in result)) {
throw new Error("expected xAI catalog provider result");
}
expect(result.provider.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1");
expect(result.provider.auth).toBe("oauth");
expect(result.provider.apiKey).toBeUndefined();
expect(result.provider.models.map((model) => model.id)).toEqual([
"grok-composer-2.5-fast",
"grok-build",
]);
const composer = result.provider.models.find((model) => model.id === "grok-composer-2.5-fast");
if (!composer) {
throw new Error("expected OAuth Composer model");
}
expect(composer.reasoning).toBe(true);
expect(result.provider.models.find((model) => model.id === "grok-build")?.reasoning).toBe(true);
const normalizedComposer = provider.normalizeResolvedModel?.({
provider: "xai",
modelId: composer.id,
model: { ...composer, provider: "xai" },
} as never);
if (!normalizedComposer) {
throw new Error("expected normalized OAuth Composer model");
}
const capture = createXaiPayloadCaptureStream();
const wrapped = provider.wrapStreamFn?.({
provider: "xai",
modelId: normalizedComposer.id,
extraParams: {},
streamFn: capture.streamFn,
} as never);
if (!wrapped) {
throw new Error("expected xAI stream wrapper");
}
void wrapped(normalizedComposer as never, { messages: [] } as never, {});
expect(capture.getCapturedPayload()).not.toHaveProperty("reasoning");
expect(capture.getCapturedPayload()?.include).toEqual(["reasoning.encrypted_content"]);
expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenCalledWith({
provider: "xai",
cfg: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
profileId: "xai-profile",
lockedProfile: true,
});
const fetchCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit] | undefined;
expect(fetchCall?.[0]).toBe("https://cli-chat-proxy.grok.com/v1/models");
expect(new Headers(fetchCall?.[1]?.headers).get("Authorization")).toBe(
"Bearer xai-oauth-token",
);
});
it("uses runtime OAuth profiles when xAI catalog auth resolution is empty", async () => {
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue({
apiKey: "xai-oauth-token",
mode: "oauth",
source: "profile:xai-profile",
profileId: "xai-profile",
});
const fetchMock = vi.fn(async () =>
Response.json({
data: [{ id: "grok-build", model: "grok-build", api_backend: "responses" }],
}),
);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const provider = await registerSingleProviderPlugin(plugin);
const result = await provider.catalog?.run({
config: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
env: {},
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
resolveProviderApiKey: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
}),
});
if (!result || !("provider" in result)) {
throw new Error("expected xAI catalog provider result");
}
expect(result.provider.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1");
expect(result.provider.auth).toBe("oauth");
expect(result.provider.models.map((model) => model.id)).toEqual(["grok-build"]);
expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenCalledWith({
provider: "xai",
cfg: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
});
});
it("keeps the Grok OAuth transport when xAI OAuth discovery is unavailable", async () => {
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue({
apiKey: "xai-oauth-token",
mode: "oauth",
source: "profile:xai-profile",
profileId: "xai-profile",
});
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response("temporarily unavailable", { status: 503 }),
) as unknown as typeof fetch,
);
const provider = await registerSingleProviderPlugin(plugin);
const result = await provider.catalog?.run({
config: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
env: {},
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: "stale-oauth-token",
mode: "oauth",
source: "profile",
profileId: "xai-profile",
}),
resolveProviderApiKey: () => ({
apiKey: "env-xai-key",
discoveryApiKey: "env-xai-key",
}),
});
if (!result || !("provider" in result)) {
throw new Error("expected xAI catalog provider result");
}
expect(result.provider.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1");
expect(result.provider.auth).toBe("oauth");
expect(result.provider.apiKey).toBeUndefined();
expect(result.provider.models.map((model) => model.id)).toContain("grok-build-0.1");
});
it("falls back to API-key discovery when xAI OAuth credential resolution fails", async () => {
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockRejectedValue(
new Error("expired oauth profile"),
);
const fetchMock = vi.fn(async () =>
Response.json({
data: [{ id: "grok-4.3", object: "model" }],
}),
);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const provider = await registerSingleProviderPlugin(plugin);
const result = await provider.catalog?.run({
config: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
env: {},
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: "stale-oauth-token",
mode: "oauth",
source: "profile",
profileId: "xai-profile",
}),
resolveProviderApiKey: () => ({
apiKey: "env-xai-key",
discoveryApiKey: "env-xai-key",
}),
});
if (!result || !("provider" in result)) {
throw new Error("expected xAI catalog provider result");
}
expect(result.provider.baseUrl).toBe("https://api.x.ai/v1");
expect(result.provider.apiKey).toBe("env-xai-key");
expect(result.provider.auth).toBeUndefined();
const fetchCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit] | undefined;
expect(fetchCall?.[0]).toBe("https://api.x.ai/v1/models");
expect(new Headers(fetchCall?.[1]?.headers).get("Authorization")).toBe("Bearer env-xai-key");
});
it("uses fallback API-key credentials consistently for xAI live discovery", async () => {
const fetchMock = vi.fn(async () =>
Response.json({
data: [{ id: "grok-4.3", object: "model" }],
}),
);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const provider = await registerSingleProviderPlugin(plugin);
const result = await provider.catalog?.run({
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
resolveProviderApiKey: () => ({
apiKey: "env-xai-key",
discoveryApiKey: "env-xai-key",
}),
} as never);
if (!result || !("provider" in result)) {
throw new Error("expected xAI catalog provider result");
}
expect(result.provider.apiKey).toBe("env-xai-key");
const fetchCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit] | undefined;
const fetchInit = fetchCall?.[1];
expect(new Headers(fetchInit?.headers).get("Authorization")).toBe("Bearer env-xai-key");
});
it("classifies Grok usage and spending limit errors", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(
provider.classifyFailoverReason?.({
errorMessage:
'403 {"code":"The caller does not have permission to execute the specified operation","error":"Your team team-redacted has either used all available credits or reached its monthly spending limit. To continue making API requests, please purchase more credits or raise your spending limit."}',
}),
).toBe("billing");
expect(
provider.classifyFailoverReason?.({
errorMessage:
'429 {"code":"Some resource has been exhausted","error":"Your team team-redacted has either used all available credits or reached its monthly spending limit. To continue making API requests, please purchase more credits or raise your spending limit."}',
}),
).toBe("billing");
expect(
provider.classifyFailoverReason?.({
errorMessage:
'429 {"code":"Some resource has been exhausted","error":"Rate limit exceeded"}',
}),
).toBe("rate_limit");
expect(
provider.classifyFailoverReason?.({
errorMessage:
'400 {"code":"Client specified an invalid argument","error":"Incorrect API key provided: xa***en. You can obtain an API key from https://console.x.ai."}',
}),
).toBeUndefined();
});
it("registers xAI speech providers for batch and streaming STT", async () => {
const { mediaProviders, realtimeTranscriptionProviders } = await registerProviderPlugin({
plugin,
id: "xai",
name: "xAI Provider",
});
const mediaProvider = requireEntry(mediaProviders, "xai");
expect(mediaProvider.capabilities).toEqual(["audio"]);
expect(mediaProvider.defaultModels).toEqual({ audio: "grok-stt" });
const realtimeProvider = requireEntry(realtimeTranscriptionProviders, "xai");
expect(realtimeProvider.label).toBe("xAI Realtime Transcription");
expect(realtimeProvider.aliases).toContain("xai-realtime");
});
it("declares setup auto-enable reasons for plugin-owned tool config", () => {
const probe = registerXaiAutoEnableProbe();
expect(
probe({
config: { plugins: { entries: { xai: { config: { xSearch: { enabled: true } } } } } },
env: {},
}),
).toBe("xai tool configured");
expect(
probe({
config: {
plugins: { entries: { xai: { config: { codeExecution: { enabled: true } } } } },
},
env: {},
}),
).toBe("xai tool configured");
expect(probe({ config: {}, env: {} })).toBeNull();
});
it("owns replay policy for xAI OpenAI-compatible transports", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const completionsPolicy = provider.buildReplayPolicy?.({
provider: "xai",
modelApi: "openai-completions",
modelId: "grok-3",
} as never);
expect(completionsPolicy?.sanitizeToolCallIds).toBe(true);
expect(completionsPolicy?.toolCallIdMode).toBe("strict");
expect(completionsPolicy?.applyAssistantFirstOrderingFix).toBe(true);
expect(completionsPolicy?.validateGeminiTurns).toBe(true);
expect(completionsPolicy?.validateAnthropicTurns).toBe(true);
const responsesPolicy = provider.buildReplayPolicy?.({
provider: "xai",
modelApi: "openai-responses",
modelId: "grok-4-fast",
} as never);
expect(responsesPolicy?.sanitizeToolCallIds).toBe(true);
expect(responsesPolicy?.toolCallIdMode).toBe("strict");
expect(responsesPolicy?.applyAssistantFirstOrderingFix).toBe(false);
expect(responsesPolicy?.validateGeminiTurns).toBe(false);
expect(responsesPolicy?.validateAnthropicTurns).toBe(false);
});
it("wires provider stream shaping for fast mode and tool-stream defaults", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const capture = createXaiPayloadCaptureStream();
const wrapped = provider.wrapStreamFn?.({
provider: "xai",
modelId: "grok-4",
extraParams: { fastMode: true },
streamFn: capture.streamFn,
} as never);
runXaiGrok4ResponseStream(wrapped);
expectXaiFastToolStreamShaping(capture);
});
it("defaults tool_stream extra params but preserves explicit values", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(
provider.prepareExtraParams?.({
provider: "xai",
modelId: "grok-4",
extraParams: { fastMode: true },
} as never),
).toEqual({
fastMode: true,
tool_stream: true,
});
const explicit = { fastMode: true, tool_stream: false };
expect(
provider.prepareExtraParams?.({
provider: "xai",
modelId: "grok-4",
extraParams: explicit,
} as never),
).toBe(explicit);
});
it("owns forward-compatible Grok model resolution", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const resolved = provider.resolveDynamicModel?.({
provider: "xai",
modelId: "grok-4.3",
modelRegistry: { find: () => null } as never,
providerConfig: {
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
},
} as never);
expect(resolved?.id).toBe("grok-4.3");
expect(resolved?.provider).toBe("xai");
expect(resolved?.api).toBe("openai-completions");
expect(resolved?.baseUrl).toBe("https://api.x.ai/v1");
expect(resolved?.reasoning).toBe(true);
expect(resolved?.input).toEqual(["text", "image"]);
expect(resolved?.contextWindow).toBe(1_000_000);
});
it("marks modern Grok refs without accepting multi-agent ids", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(
provider.isModernModelRef?.({
provider: "xai",
modelId: "grok-4.3",
} as never),
).toBe(true);
expect(
provider.isModernModelRef?.({
provider: "xai",
modelId: "grok-4.20-multi-agent-experimental-beta-0304",
} as never),
).toBe(false);
});
it("owns xai compat flags for direct and downstream routed models", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const normalized = provider.normalizeResolvedModel?.({
provider: "xai",
modelId: "grok-4.3",
model: createProviderModel({ id: "grok-4.3" }),
} as never);
expect(normalized?.thinkingLevelMap).toEqual({
off: null,
minimal: "low",
low: "low",
medium: "medium",
high: "high",
xhigh: "high",
});
const olderReasoningModel = provider.normalizeResolvedModel?.({
provider: "xai",
modelId: "grok-4-1-fast",
model: createProviderModel({ id: "grok-4-1-fast" }),
} as never);
expect(olderReasoningModel?.thinkingLevelMap).toEqual({
off: null,
minimal: null,
low: null,
medium: null,
high: null,
xhigh: null,
});
const normalizedCompat = normalized?.compat as
| {
toolSchemaProfile?: string;
nativeWebSearchTool?: boolean;
toolCallArgumentsEncoding?: string;
}
| undefined;
expect(normalizedCompat?.toolSchemaProfile).toBe("xai");
expect(normalizedCompat?.nativeWebSearchTool).toBe(true);
expect(normalizedCompat?.toolCallArgumentsEncoding).toBe("html-entities");
});
});

266
extensions/xai/index.ts Normal file
View File

@@ -0,0 +1,266 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Xai plugin entrypoint registers its OpenClaw integration.
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";
import { jsonResult } from "openclaw/plugin-sdk/provider-web-search";
import {
applyXaiRuntimeModelCompat,
buildXaiImageGenerationProvider,
normalizeXaiModelId,
resolveXaiTransport,
} from "./api.js";
import {
buildMissingCodeExecutionApiKeyPayload,
createCodeExecutionToolDefinition,
} from "./code-execution-tool-shared.js";
import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js";
import {
buildLiveXaiOAuthProvider,
buildLiveXaiProvider,
buildXaiProvider,
} from "./provider-catalog.js";
import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
import { resolveThinkingProfile } from "./provider-policy-api.js";
import { buildXaiRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
import { buildXaiSpeechProvider } from "./speech-provider.js";
import {
readPluginCodeExecutionConfig,
resolveCodeExecutionEnabled,
} from "./src/code-execution-config.js";
import {
isXaiToolEnabled,
resolveFallbackXaiAuth,
type XaiToolAuthContext,
} from "./src/tool-auth-shared.js";
import { resolveEffectiveXSearchConfig } from "./src/x-search-config.js";
import { wrapXaiProviderStream } from "./stream.js";
import { buildXaiMediaUnderstandingProvider } from "./stt.js";
import { buildXaiVideoGenerationProvider } from "./video-generation-provider.js";
import { createXaiWebSearchProvider } from "./web-search.js";
import {
buildMissingXSearchApiKeyPayload,
createXSearchToolDefinition,
} from "./x-search-tool-shared.js";
import {
createXaiDeviceCodeAuthMethod,
createXaiOAuthAuthMethod,
refreshXaiOAuthCredential,
} from "./xai-oauth.js";
const PROVIDER_ID = "xai";
const XAI_CREDIT_OR_SPENDING_LIMIT_RE =
/\b(?:used all available credits|monthly spending limit|purchase more credits|raise your spending limit)\b/i;
const XAI_RATE_LIMIT_RE = /\b(?:rate limit exceeded|too many requests)\b/i;
const loadCodeExecutionModule = createLazyRuntimeModule(() => import("./code-execution.js"));
const loadXSearchModule = createLazyRuntimeModule(() => import("./x-search.js"));
function classifyXaiFailoverReason(errorMessage: string) {
if (XAI_CREDIT_OR_SPENDING_LIMIT_RE.test(errorMessage)) {
return "billing" as const;
}
if (XAI_RATE_LIMIT_RE.test(errorMessage)) {
return "rate_limit" as const;
}
return undefined;
}
function hasResolvableXaiApiKey(config: unknown, auth?: XaiToolAuthContext): boolean {
return isXaiToolEnabled({ sourceConfig: config as never, auth });
}
function isCodeExecutionEnabled(config: unknown, auth?: XaiToolAuthContext): boolean {
return resolveCodeExecutionEnabled({
sourceConfig: config,
runtimeConfig: config,
config: readPluginCodeExecutionConfig(config),
auth,
});
}
function isXSearchEnabled(config: unknown, auth?: XaiToolAuthContext): boolean {
const resolved =
config && typeof config === "object"
? resolveEffectiveXSearchConfig(config as never)
: undefined;
if (resolved?.enabled === false) {
return false;
}
return hasResolvableXaiApiKey(config, auth);
}
function createLazyCodeExecutionTool(ctx: {
config?: Record<string, unknown>;
runtimeConfig?: Record<string, unknown>;
hasAuthForProvider?: XaiToolAuthContext["hasAuthForProvider"];
resolveApiKeyForProvider?: XaiToolAuthContext["resolveApiKeyForProvider"];
}) {
const effectiveConfig = ctx.runtimeConfig ?? ctx.config;
if (!isCodeExecutionEnabled(effectiveConfig, ctx)) {
return null;
}
return createCodeExecutionToolDefinition(
async (toolCallId: string, args: Record<string, unknown>) => {
const { createCodeExecutionTool } = await loadCodeExecutionModule();
const tool = createCodeExecutionTool({
config: ctx.config as never,
runtimeConfig: (ctx.runtimeConfig as never) ?? null,
auth: ctx,
});
if (!tool) {
return jsonResult(buildMissingCodeExecutionApiKeyPayload());
}
return await tool.execute(toolCallId, args);
},
);
}
function createLazyXSearchTool(ctx: {
config?: Record<string, unknown>;
runtimeConfig?: Record<string, unknown>;
hasAuthForProvider?: XaiToolAuthContext["hasAuthForProvider"];
resolveApiKeyForProvider?: XaiToolAuthContext["resolveApiKeyForProvider"];
}) {
const effectiveConfig = ctx.runtimeConfig ?? ctx.config;
if (!isXSearchEnabled(effectiveConfig, ctx)) {
return null;
}
return createXSearchToolDefinition(async (toolCallId: string, args: Record<string, unknown>) => {
const { createXSearchTool } = await loadXSearchModule();
const tool = createXSearchTool({
config: ctx.config as never,
runtimeConfig: (ctx.runtimeConfig as never) ?? null,
auth: ctx,
});
if (!tool) {
return jsonResult(buildMissingXSearchApiKeyPayload());
}
return await tool.execute(toolCallId, args);
});
}
export default defineSingleProviderPluginEntry({
id: "xai",
name: "xAI Plugin",
description: "Bundled xAI plugin",
provider: {
label: "xAI",
aliases: ["x-ai"],
docsPath: "/providers/xai",
auth: [
{
methodId: "api-key",
label: "xAI API key",
hint: "API key",
optionKey: "xaiApiKey",
flagName: "--xai-api-key",
envVar: "XAI_API_KEY",
promptMessage: "Enter xAI API key",
defaultModel: XAI_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyXaiConfig(cfg),
wizard: {
groupLabel: "xAI (Grok)",
},
},
],
extraAuth: [createXaiOAuthAuthMethod(), createXaiDeviceCodeAuthMethod()],
catalog: {
order: "simple",
run: async (ctx) => {
const auth = ctx.resolveProviderAuth(PROVIDER_ID);
try {
const { resolveApiKeyForProvider } =
await import("openclaw/plugin-sdk/provider-auth-runtime");
const runtimeAuth = await resolveApiKeyForProvider({
provider: PROVIDER_ID,
cfg: ctx.config,
...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}),
...(ctx.workspaceDir ? { workspaceDir: ctx.workspaceDir } : {}),
...(auth.profileId
? {
profileId: auth.profileId,
lockedProfile: true,
}
: {}),
});
if (runtimeAuth?.mode === "oauth" && runtimeAuth.apiKey) {
return {
provider: await buildLiveXaiOAuthProvider({
discoveryApiKey: runtimeAuth.apiKey,
}),
};
}
} catch {
if (auth.mode === "oauth") {
// OAuth discovery is advisory; fall through so configured API-key
// auth can still publish the standard xAI catalog.
}
}
if (auth.apiKey) {
return {
provider: await buildLiveXaiProvider({
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
};
}
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID);
if (!apiKey.apiKey) {
return null;
}
return {
provider: await buildLiveXaiProvider({
apiKey: apiKey.apiKey,
discoveryApiKey: apiKey.discoveryApiKey,
}),
};
},
staticRun: async () => ({
provider: buildXaiProvider(),
}),
},
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
prepareExtraParams: (ctx) => defaultToolStreamExtraParams(ctx.extraParams),
wrapStreamFn: wrapXaiProviderStream,
// Provider-specific fallback auth stays owned by the xAI plugin so core
// auth/discovery code can consume it generically without parsing xAI's
// private config layout. Callers may receive a real key from the active
// runtime snapshot or a non-secret SecretRef marker from source config.
resolveSyntheticAuth: ({ config }) => {
const fallbackAuth = resolveFallbackXaiAuth(config);
if (!fallbackAuth) {
return undefined;
}
return {
apiKey: fallbackAuth.apiKey,
source: fallbackAuth.source,
mode: "api-key" as const,
};
},
normalizeResolvedModel: ({ model }) => applyXaiRuntimeModelCompat(model),
normalizeTransport: ({ provider, api, baseUrl }) =>
resolveXaiTransport({ provider, api, baseUrl }),
normalizeModelId: ({ modelId }) => normalizeXaiModelId(modelId),
resolveDynamicModel: (ctx) => resolveXaiForwardCompatModel({ providerId: PROVIDER_ID, ctx }),
refreshOAuth: refreshXaiOAuthCredential,
resolveThinkingProfile,
isModernModelRef: ({ modelId }) => isModernXaiModel(modelId),
classifyFailoverReason: ({ errorMessage }) => classifyXaiFailoverReason(errorMessage),
},
register(api) {
api.registerWebSearchProvider(createXaiWebSearchProvider());
api.registerMediaUnderstandingProvider(buildXaiMediaUnderstandingProvider());
api.registerVideoGenerationProvider(buildXaiVideoGenerationProvider());
api.registerImageGenerationProvider(buildXaiImageGenerationProvider());
api.registerSpeechProvider(buildXaiSpeechProvider());
api.registerRealtimeTranscriptionProvider(buildXaiRealtimeTranscriptionProvider());
api.registerTool((ctx) => createLazyCodeExecutionTool(ctx), { name: "code_execution" });
api.registerTool((ctx) => createLazyXSearchTool(ctx), { name: "x_search" });
},
});

View File

@@ -0,0 +1,35 @@
// Xai plugin module implements model compat behavior.
import {
applyModelCompatPatch,
type ModelCompatConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
export { normalizeXaiModelId as normalizeNativeXaiModelId } from "./model-id.js";
export const XAI_TOOL_SCHEMA_PROFILE = "xai";
export const HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING = "html-entities";
const XAI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
"minLength",
"maxLength",
"minItems",
"maxItems",
"minContains",
"maxContains",
]);
function resolveXaiModelCompatPatch(): ModelCompatConfig {
return {
toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE,
unsupportedToolSchemaKeywords: Array.from(XAI_UNSUPPORTED_SCHEMA_KEYWORDS),
nativeWebSearchTool: true,
toolCallArgumentsEncoding: HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING,
};
}
export function applyXaiModelCompat<T extends { compat?: unknown }>(model: T): T {
return applyModelCompatPatch(
model as T & { compat?: ModelCompatConfig },
resolveXaiModelCompatPatch(),
) as T;
}

View File

@@ -0,0 +1,346 @@
// Xai plugin module implements model definitions behavior.
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
export const XAI_BASE_URL = "https://api.x.ai/v1";
export const XAI_DEFAULT_IMAGE_MODEL = "grok-imagine-image";
export const XAI_IMAGE_MODELS = ["grok-imagine-image", "grok-imagine-image-quality"] as const;
export const XAI_DEFAULT_CONTEXT_WINDOW = 1_000_000;
const XAI_LARGE_CONTEXT_WINDOW = 2_000_000;
const XAI_GROK_4_CONTEXT_WINDOW = 256_000;
const XAI_CODE_CONTEXT_WINDOW = 256_000;
export const XAI_DEFAULT_MAX_TOKENS = 64_000;
const XAI_LEGACY_CONTEXT_WINDOW = 131_072;
const XAI_LEGACY_MAX_TOKENS = 8_192;
export const XAI_DEFAULT_MODEL_ID = "grok-4.3";
type XaiCost = ModelDefinitionConfig["cost"];
type XaiCatalogEntry = {
id: string;
name: string;
reasoning: boolean;
input?: ModelDefinitionConfig["input"];
contextWindow: number;
maxTokens?: number;
cost: XaiCost;
};
const XAI_GROK_4_COST = {
input: 3,
output: 15,
cacheRead: 0.75,
cacheWrite: 0,
} satisfies XaiCost;
const XAI_FAST_COST = {
input: 0.2,
output: 0.5,
cacheRead: 0.05,
cacheWrite: 0,
} satisfies XaiCost;
const XAI_GROK_420_COST = {
input: 1.25,
output: 2.5,
cacheRead: 0.2,
cacheWrite: 0,
} satisfies XaiCost;
const XAI_GROK_43_COST = {
input: 1.25,
output: 2.5,
cacheRead: 0.2,
cacheWrite: 0,
} satisfies XaiCost;
const XAI_GROK_BUILD_COST = {
input: 1,
output: 2,
cacheRead: 0.2,
cacheWrite: 0,
} satisfies XaiCost;
const XAI_CODE_FAST_COST = {
input: 0.2,
output: 1.5,
cacheRead: 0.02,
cacheWrite: 0,
} satisfies XaiCost;
const XAI_MODEL_CATALOG = [
{
id: "grok-build-0.1",
name: "Grok Build 0.1",
reasoning: true,
input: ["text", "image"],
contextWindow: XAI_CODE_CONTEXT_WINDOW,
cost: XAI_GROK_BUILD_COST,
},
{
id: "grok-3",
name: "Grok 3",
reasoning: false,
input: ["text"],
contextWindow: XAI_LEGACY_CONTEXT_WINDOW,
maxTokens: XAI_LEGACY_MAX_TOKENS,
cost: XAI_GROK_4_COST,
},
{
id: "grok-3-fast",
name: "Grok 3 Fast",
reasoning: false,
input: ["text"],
contextWindow: XAI_LEGACY_CONTEXT_WINDOW,
maxTokens: XAI_LEGACY_MAX_TOKENS,
cost: { input: 5, output: 25, cacheRead: 1.25, cacheWrite: 0 },
},
{
id: "grok-3-mini",
name: "Grok 3 Mini",
reasoning: true,
input: ["text"],
contextWindow: XAI_LEGACY_CONTEXT_WINDOW,
maxTokens: XAI_LEGACY_MAX_TOKENS,
cost: { input: 0.3, output: 0.5, cacheRead: 0.075, cacheWrite: 0 },
},
{
id: "grok-3-mini-fast",
name: "Grok 3 Mini Fast",
reasoning: true,
input: ["text"],
contextWindow: XAI_LEGACY_CONTEXT_WINDOW,
maxTokens: XAI_LEGACY_MAX_TOKENS,
cost: { input: 0.6, output: 4, cacheRead: 0.15, cacheWrite: 0 },
},
{
id: "grok-4.3",
name: "Grok 4.3",
reasoning: true,
input: ["text", "image"],
contextWindow: XAI_DEFAULT_CONTEXT_WINDOW,
maxTokens: XAI_DEFAULT_MAX_TOKENS,
cost: XAI_GROK_43_COST,
},
{
id: "grok-4",
name: "Grok 4",
reasoning: true,
input: ["text"],
contextWindow: XAI_GROK_4_CONTEXT_WINDOW,
maxTokens: XAI_DEFAULT_MAX_TOKENS,
cost: XAI_GROK_4_COST,
},
{
id: "grok-4-0709",
name: "Grok 4 0709",
reasoning: false,
input: ["text"],
contextWindow: XAI_GROK_4_CONTEXT_WINDOW,
maxTokens: XAI_DEFAULT_MAX_TOKENS,
cost: XAI_GROK_4_COST,
},
{
id: "grok-4-fast",
name: "Grok 4 Fast",
reasoning: true,
input: ["text", "image"],
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
maxTokens: 30_000,
cost: XAI_FAST_COST,
},
{
id: "grok-4-fast-non-reasoning",
name: "Grok 4 Fast (Non-Reasoning)",
reasoning: false,
input: ["text", "image"],
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
maxTokens: 30_000,
cost: XAI_FAST_COST,
},
{
id: "grok-4-1-fast",
name: "Grok 4.1 Fast",
reasoning: true,
input: ["text", "image"],
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
maxTokens: 30_000,
cost: XAI_FAST_COST,
},
{
id: "grok-4-1-fast-non-reasoning",
name: "Grok 4.1 Fast (Non-Reasoning)",
reasoning: false,
input: ["text", "image"],
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
maxTokens: 30_000,
cost: XAI_FAST_COST,
},
{
id: "grok-4.20-beta-latest-reasoning",
name: "Grok 4.20 Beta Latest (Reasoning)",
reasoning: true,
input: ["text", "image"],
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
maxTokens: 30_000,
cost: XAI_GROK_420_COST,
},
{
id: "grok-4.20-beta-latest-non-reasoning",
name: "Grok 4.20 Beta Latest (Non-Reasoning)",
reasoning: false,
input: ["text", "image"],
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
maxTokens: 30_000,
cost: XAI_GROK_420_COST,
},
] as const satisfies readonly XaiCatalogEntry[];
const XAI_SELECTABLE_MODEL_IDS = new Set<string>([
"grok-build-0.1",
"grok-4.3",
"grok-4.20-beta-latest-reasoning",
"grok-4.20-beta-latest-non-reasoning",
]);
const XAI_GROK_BUILD_ALIASES = new Set<string>([
"grok-code-fast-1",
"grok-code-fast",
"grok-code-fast-1-0825",
]);
const XAI_RETIRED_BUILTIN_MODEL_IDS = new Set<string>(
XAI_MODEL_CATALOG.map((entry) => entry.id).filter((id) => !XAI_SELECTABLE_MODEL_IDS.has(id)),
);
function normalizeXaiCatalogModelId(modelId: string): string {
const lower = normalizeOptionalLowercaseString(modelId) ?? "";
const unprefixed = lower.startsWith("xai/") ? lower.slice("xai/".length) : lower;
if (XAI_GROK_BUILD_ALIASES.has(unprefixed)) {
return "grok-build-0.1";
}
return unprefixed;
}
export function isRetiredXaiBuiltinModelId(modelId: string): boolean {
const lower = normalizeOptionalLowercaseString(modelId) ?? "";
const unprefixed = lower.startsWith("xai/") ? lower.slice("xai/".length) : lower;
if (XAI_GROK_BUILD_ALIASES.has(unprefixed)) {
return true;
}
return XAI_RETIRED_BUILTIN_MODEL_IDS.has(normalizeXaiCatalogModelId(modelId));
}
function toModelDefinition(entry: XaiCatalogEntry): ModelDefinitionConfig {
return {
id: entry.id,
name: entry.name,
reasoning: entry.reasoning,
input: entry.input ?? ["text"],
cost: entry.cost,
contextWindow: entry.contextWindow,
maxTokens: entry.maxTokens ?? XAI_DEFAULT_MAX_TOKENS,
};
}
export function buildXaiModelDefinition(): ModelDefinitionConfig {
return toModelDefinition(
XAI_MODEL_CATALOG.find((entry) => entry.id === XAI_DEFAULT_MODEL_ID) ?? {
id: XAI_DEFAULT_MODEL_ID,
name: "Grok 4.3",
reasoning: true,
input: ["text", "image"],
contextWindow: XAI_DEFAULT_CONTEXT_WINDOW,
maxTokens: XAI_DEFAULT_MAX_TOKENS,
cost: XAI_GROK_43_COST,
},
);
}
export function buildXaiCatalogModels(): ModelDefinitionConfig[] {
return XAI_MODEL_CATALOG.filter((entry) => XAI_SELECTABLE_MODEL_IDS.has(entry.id)).map((entry) =>
toModelDefinition(entry),
);
}
export function resolveXaiCatalogEntry(modelId: string) {
const trimmed = modelId.trim();
const lower = normalizeXaiCatalogModelId(modelId);
const exact = XAI_MODEL_CATALOG.find(
(entry) => normalizeOptionalLowercaseString(entry.id) === lower,
);
if (exact) {
return toModelDefinition(exact);
}
if (lower.includes("multi-agent")) {
return undefined;
}
if (lower.startsWith("grok-code-fast")) {
return toModelDefinition({
id: trimmed,
name: trimmed,
reasoning: true,
input: ["text"],
contextWindow: XAI_CODE_CONTEXT_WINDOW,
maxTokens: 10_000,
cost: XAI_CODE_FAST_COST,
});
}
if (
lower.startsWith("grok-3-mini-fast") ||
lower.startsWith("grok-3-mini") ||
lower.startsWith("grok-3-fast") ||
lower.startsWith("grok-3")
) {
const legacyCost = lower.startsWith("grok-3-mini-fast")
? { input: 0.6, output: 4, cacheRead: 0.15, cacheWrite: 0 }
: lower.startsWith("grok-3-mini")
? { input: 0.3, output: 0.5, cacheRead: 0.075, cacheWrite: 0 }
: lower.startsWith("grok-3-fast")
? { input: 5, output: 25, cacheRead: 1.25, cacheWrite: 0 }
: XAI_GROK_4_COST;
return toModelDefinition({
id: trimmed,
name: trimmed,
reasoning: lower.includes("mini"),
input: ["text"],
contextWindow: XAI_LEGACY_CONTEXT_WINDOW,
maxTokens: XAI_LEGACY_MAX_TOKENS,
cost: legacyCost,
});
}
if (
lower.startsWith("grok-4.3") ||
lower.startsWith("grok-4.20") ||
lower.startsWith("grok-4-1") ||
lower.startsWith("grok-4-fast")
) {
return toModelDefinition({
id: trimmed,
name: trimmed,
reasoning: !lower.includes("non-reasoning"),
input: ["text", "image"],
contextWindow: lower.startsWith("grok-4.3")
? XAI_DEFAULT_CONTEXT_WINDOW
: XAI_LARGE_CONTEXT_WINDOW,
maxTokens: lower.startsWith("grok-4.3") ? XAI_DEFAULT_MAX_TOKENS : 30_000,
cost: lower.startsWith("grok-4.3")
? XAI_GROK_43_COST
: lower.startsWith("grok-4.20")
? XAI_GROK_420_COST
: XAI_FAST_COST,
});
}
if (lower.startsWith("grok-4")) {
return toModelDefinition({
id: modelId.trim(),
name: modelId.trim(),
reasoning: lower.includes("reasoning"),
input: ["text"],
contextWindow: XAI_GROK_4_CONTEXT_WINDOW,
maxTokens: XAI_DEFAULT_MAX_TOKENS,
cost: XAI_GROK_4_COST,
});
}
return undefined;
}

View File

@@ -0,0 +1,33 @@
// Xai tests cover model id plugin behavior.
import { describe, expect, it } from "vitest";
import { normalizeXaiModelId } from "./api.js";
describe("normalizeXaiModelId", () => {
it("maps deprecated grok 4.20 beta ids to GA ids", () => {
expect(normalizeXaiModelId("grok-4.20-experimental-beta-0304-reasoning")).toBe(
"grok-4.20-beta-latest-reasoning",
);
expect(normalizeXaiModelId("grok-4.20-experimental-beta-0304-non-reasoning")).toBe(
"grok-4.20-beta-latest-non-reasoning",
);
});
it("maps older fast and 4.20 ids to the current OpenClaw-backed ids", () => {
expect(normalizeXaiModelId("grok-code-fast-1")).toBe("grok-build-0.1");
expect(normalizeXaiModelId("grok-code-fast")).toBe("grok-build-0.1");
expect(normalizeXaiModelId("grok-code-fast-1-0825")).toBe("grok-build-0.1");
expect(normalizeXaiModelId("grok-4-fast-reasoning")).toBe("grok-4-fast");
expect(normalizeXaiModelId("grok-4-1-fast-reasoning")).toBe("grok-4-1-fast");
expect(normalizeXaiModelId("grok-4.20-reasoning")).toBe("grok-4.20-beta-latest-reasoning");
expect(normalizeXaiModelId("grok-4.20-non-reasoning")).toBe(
"grok-4.20-beta-latest-non-reasoning",
);
});
it("leaves current xai model ids unchanged", () => {
expect(normalizeXaiModelId("grok-4.20-beta-latest-reasoning")).toBe(
"grok-4.20-beta-latest-reasoning",
);
expect(normalizeXaiModelId("grok-4")).toBe("grok-4");
});
});

View File

@@ -0,0 +1,25 @@
// Xai plugin module implements model id behavior.
export function normalizeXaiModelId(id: string): string {
if (id === "grok-code-fast-1" || id === "grok-code-fast" || id === "grok-code-fast-1-0825") {
return "grok-build-0.1";
}
if (id === "grok-4-fast-reasoning") {
return "grok-4-fast";
}
if (id === "grok-4-1-fast-reasoning") {
return "grok-4-1-fast";
}
if (id === "grok-4.20-experimental-beta-0304-reasoning") {
return "grok-4.20-beta-latest-reasoning";
}
if (id === "grok-4.20-experimental-beta-0304-non-reasoning") {
return "grok-4.20-beta-latest-non-reasoning";
}
if (id === "grok-4.20-reasoning") {
return "grok-4.20-beta-latest-reasoning";
}
if (id === "grok-4.20-non-reasoning") {
return "grok-4.20-beta-latest-non-reasoning";
}
return id;
}

View File

@@ -0,0 +1,92 @@
// Xai tests cover onboard plugin behavior.
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "openclaw/plugin-sdk/provider-onboard";
import {
createConfigWithFallbacks,
createLegacyProviderConfig,
EXPECTED_FALLBACKS,
} from "openclaw/plugin-sdk/provider-test-contracts";
import { describe, expect, it } from "vitest";
import { applyXaiConfig, applyXaiProviderConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js";
describe("xai onboard", () => {
it("adds xAI provider with correct settings", () => {
const cfg = applyXaiConfig({});
expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1");
expect(cfg.models?.providers?.xai?.api).toBe("openai-responses");
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(XAI_DEFAULT_MODEL_REF);
});
it("merges xAI models and keeps existing provider overrides", () => {
const legacy = createLegacyProviderConfig({
providerId: "xai",
api: "anthropic-messages",
modelId: "custom-model",
modelName: "Custom",
});
const xaiProvider = legacy.models?.providers?.xai;
if (!xaiProvider) {
throw new Error("expected xAI provider fixture");
}
xaiProvider.models.push(
{
id: "grok-3",
name: "Grok 3",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
{
id: "grok-code-fast-1",
name: "Grok Code Fast 1",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
);
const cfg = applyXaiProviderConfig(legacy);
expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1");
expect(cfg.models?.providers?.xai?.api).toBe("openai-responses");
expect(cfg.models?.providers?.xai?.apiKey).toBe("old-key");
expect(cfg.models?.providers?.xai?.models.map((m) => m.id)).toEqual([
"custom-model",
"grok-build-0.1",
"grok-4.3",
"grok-4.20-beta-latest-reasoning",
"grok-4.20-beta-latest-non-reasoning",
]);
});
it("publishes current xAI models newest first for fresh setup", () => {
const cfg = applyXaiProviderConfig({});
expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1");
expect(cfg.models?.providers?.xai?.api).toBe("openai-responses");
expect(cfg.models?.providers?.xai?.models.map((m) => m.id)).toEqual([
"grok-build-0.1",
"grok-4.3",
"grok-4.20-beta-latest-reasoning",
"grok-4.20-beta-latest-non-reasoning",
]);
});
it("adds expected alias for the default model", () => {
const cfg = applyXaiProviderConfig({});
expect(cfg.agents?.defaults?.models?.[XAI_DEFAULT_MODEL_REF]?.alias).toBe("Grok");
});
it("preserves existing model fallbacks", () => {
const cfg = applyXaiConfig(createConfigWithFallbacks());
expect(resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)).toEqual([
...EXPECTED_FALLBACKS,
]);
});
});

57
extensions/xai/onboard.ts Normal file
View File

@@ -0,0 +1,57 @@
// Xai setup module handles plugin onboarding behavior.
import {
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { XAI_BASE_URL, XAI_DEFAULT_MODEL_ID } from "./model-definitions.js";
import { buildXaiCatalogModels, isRetiredXaiBuiltinModelId } from "./model-definitions.js";
export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`;
const xaiPresetAppliers = createModelCatalogPresetAppliers<
["openai-completions" | "openai-responses"]
>({
primaryModelRef: XAI_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig, api) => ({
providerId: "xai",
api,
baseUrl: XAI_BASE_URL,
catalogModels: buildXaiCatalogModels(),
aliases: [{ modelRef: XAI_DEFAULT_MODEL_REF, alias: "Grok" }],
}),
});
function pruneRetiredXaiBuiltinModels(cfg: OpenClawConfig): OpenClawConfig {
const provider = cfg.models?.providers?.xai;
if (!provider || !Array.isArray(provider.models)) {
return cfg;
}
const models = provider.models.filter((model) => !isRetiredXaiBuiltinModelId(model.id));
if (models.length === provider.models.length) {
return cfg;
}
return {
...cfg,
models: {
...cfg.models,
providers: {
...cfg.models?.providers,
xai: {
...provider,
models,
},
},
},
};
}
export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return xaiPresetAppliers.applyProviderConfig(
pruneRetiredXaiBuiltinModels(cfg),
"openai-responses",
);
}
export function applyXaiConfig(cfg: OpenClawConfig): OpenClawConfig {
return xaiPresetAppliers.applyConfig(pruneRetiredXaiBuiltinModels(cfg), "openai-responses");
}

View File

@@ -0,0 +1,314 @@
{
"id": "xai",
"activation": {
"onStartup": false
},
"enabledByDefault": true,
"providers": ["xai"],
"providerCatalogEntry": "./provider-discovery.ts",
"modelIdNormalization": {
"providers": {
"xai": {
"aliases": {
"grok-4.20-experimental-beta-0304-reasoning": "grok-4.20-beta-latest-reasoning",
"grok-4.20-experimental-beta-0304-non-reasoning": "grok-4.20-beta-latest-non-reasoning",
"grok-4.20-reasoning": "grok-4.20-beta-latest-reasoning",
"grok-4.20-non-reasoning": "grok-4.20-beta-latest-non-reasoning"
}
}
}
},
"providerEndpoints": [
{
"endpointClass": "xai-native",
"hosts": ["api.x.ai"]
}
],
"providerRequest": {
"providers": {
"xai": {
"family": "xai"
}
}
},
"modelCatalog": {
"suppressions": [
{
"provider": "xai",
"model": "grok-4.20-multi-agent-0309",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
},
{
"provider": "xai",
"model": "grok-4.20-multi-agent",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
},
{
"provider": "xai",
"model": "grok-4.20-multi-agent-latest",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
},
{
"provider": "xai",
"model": "grok-4.20-multi-agent-beta-latest",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
},
{
"provider": "xai",
"model": "grok-4.20-multi-agent-experimental-beta-0304",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
},
{
"provider": "xai",
"model": "grok-4.20-multi-agent-experimental-beta-latest",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
},
{
"provider": "xai",
"model": "grok-4.20-multi-agent-beta-0309",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
}
]
},
"syntheticAuthRefs": ["xai"],
"setup": {
"providers": [
{
"id": "xai",
"envVars": ["XAI_API_KEY"]
}
]
},
"providerAuthChoices": [
{
"provider": "xai",
"method": "api-key",
"choiceId": "xai-api-key",
"choiceLabel": "xAI API key",
"groupId": "xai",
"groupLabel": "xAI (Grok)",
"groupHint": "API key or OAuth",
"onboardingFeatured": true,
"optionKey": "xaiApiKey",
"cliFlag": "--xai-api-key",
"cliOption": "--xai-api-key <key>",
"cliDescription": "xAI API key"
},
{
"provider": "xai",
"method": "oauth",
"choiceId": "xai-oauth",
"choiceLabel": "xAI OAuth",
"choiceHint": "Remote-friendly browser sign-in without a localhost callback",
"groupId": "xai",
"groupLabel": "xAI (Grok)",
"groupHint": "API key or OAuth",
"onboardingFeatured": true
},
{
"provider": "xai",
"method": "device-code",
"choiceId": "xai-device-code",
"choiceLabel": "xAI device code",
"choiceHint": "Compatibility alias for xAI OAuth device-code sign-in",
"assistantVisibility": "manual-only",
"groupId": "xai",
"groupLabel": "xAI (Grok)",
"groupHint": "API key or OAuth"
}
],
"uiHints": {
"webSearch.apiKey": {
"label": "Grok Search API Key",
"help": "Optional xAI API key for Grok web search; xAI OAuth or XAI_API_KEY can satisfy it.",
"sensitive": true
},
"webSearch.model": {
"label": "Grok Search Model",
"help": "Grok model override for web search."
},
"webSearch.baseUrl": {
"label": "Grok Search Base URL",
"help": "Optional xAI Responses API base URL for Grok web_search and x_search fallbacks."
},
"webSearch.inlineCitations": {
"label": "Inline Citations",
"help": "Include inline markdown citations in Grok responses."
},
"codeExecution.enabled": {
"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.baseUrl": {
"label": "X Search Base URL",
"help": "Optional xAI Responses API base URL for x_search requests."
},
"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."
},
"codeExecution.maxTurns": {
"label": "Code Execution Max Turns",
"help": "Optional max internal tool turns xAI may use for code_execution."
},
"codeExecution.timeoutSeconds": {
"label": "Code Execution Timeout",
"help": "Timeout in seconds for code_execution requests."
}
},
"contracts": {
"webSearchProviders": ["grok"],
"videoGenerationProviders": ["xai"],
"mediaUnderstandingProviders": ["xai"],
"speechProviders": ["xai"],
"realtimeTranscriptionProviders": ["xai"],
"imageGenerationProviders": ["xai"],
"tools": ["code_execution", "x_search"]
},
"mediaUnderstandingProviderMetadata": {
"xai": {
"capabilities": ["audio"],
"defaultModels": {
"audio": "grok-stt"
},
"autoPriority": {
"audio": 25
}
}
},
"toolMetadata": {
"code_execution": {
"authSignals": [
{
"provider": "xai"
}
],
"configSignals": [
{
"rootPath": "plugins.entries.xai.config",
"overlayPath": "webSearch",
"required": ["apiKey"]
},
{
"rootPath": "tools.web.search.grok",
"required": ["apiKey"]
}
]
},
"x_search": {
"replaySafe": true,
"authSignals": [
{
"provider": "xai"
}
],
"configSignals": [
{
"rootPath": "plugins.entries.xai.config",
"overlayPath": "webSearch",
"required": ["apiKey"]
},
{
"rootPath": "tools.web.search.grok",
"required": ["apiKey"]
}
]
}
},
"configContracts": {
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"model": {
"type": "string"
},
"baseUrl": {
"type": "string"
},
"inlineCitations": {
"type": "boolean"
}
}
},
"xSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"model": {
"type": "string"
},
"baseUrl": {
"type": "string"
},
"inlineCitations": {
"type": "boolean"
},
"maxTurns": {
"type": "number"
},
"timeoutSeconds": {
"type": "number"
},
"cacheTtlMinutes": {
"type": "number"
}
}
},
"codeExecution": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"model": {
"type": "string"
},
"maxTurns": {
"type": "number"
},
"timeoutSeconds": {
"type": "number"
}
}
}
}
}
}

View File

@@ -0,0 +1,34 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const manifest = JSON.parse(
readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
) as {
modelCatalog?: {
suppressions?: Array<{ provider?: string; model?: string }>;
};
};
const XAI_MULTI_AGENT_MODELS = [
"grok-4.20-multi-agent-0309",
"grok-4.20-multi-agent",
"grok-4.20-multi-agent-latest",
"grok-4.20-multi-agent-beta-latest",
"grok-4.20-multi-agent-experimental-beta-0304",
"grok-4.20-multi-agent-experimental-beta-latest",
"grok-4.20-multi-agent-beta-0309",
] as const;
describe("xAI plugin manifest", () => {
it("suppresses the unsupported multi-agent model aliases", () => {
const suppressionRefs = new Set(
(manifest.modelCatalog?.suppressions ?? []).map(
(suppression) => `${suppression.provider}/${suppression.model}`,
),
);
for (const model of XAI_MULTI_AGENT_MODELS) {
expect(suppressionRefs).toContain(`xai/${model}`);
}
});
});

View File

@@ -0,0 +1,19 @@
{
"name": "@openclaw/xai-plugin",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw xAI plugin",
"type": "module",
"dependencies": {
"typebox": "1.3.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"ws": "8.21.0"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,212 @@
// Xai provider module implements model/runtime integration.
import {
buildLiveModelProviderConfig,
getCachedLiveProviderModelRows,
type LiveModelCatalogFetchGuard,
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
buildXaiCatalogModels,
resolveXaiCatalogEntry,
XAI_BASE_URL,
XAI_DEFAULT_CONTEXT_WINDOW,
XAI_IMAGE_MODELS,
XAI_DEFAULT_MAX_TOKENS,
} from "./model-definitions.js";
const PROVIDER_ID = "xai";
const XAI_MODELS_ENDPOINT = `${XAI_BASE_URL}/models`;
const XAI_GROK_OAUTH_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
const XAI_GROK_OAUTH_MODELS_ENDPOINT = `${XAI_GROK_OAUTH_BASE_URL}/models`;
const XAI_MODELS_CACHE_TTL_MS = 60_000;
const XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS = 60_000;
// Composer emits replayable Responses reasoning, but the OAuth catalog omits that capability.
// Keep it classified here or the stream wrapper will omit encrypted reasoning from replay.
const XAI_GROK_OAUTH_REASONING_MODEL_IDS = new Set(["grok-composer-2.5-fast"]);
const XAI_UNKNOWN_MODEL_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
} satisfies ModelDefinitionConfig["cost"];
export function buildXaiProvider(
api: ModelProviderConfig["api"] = "openai-responses",
): ModelProviderConfig {
return {
baseUrl: XAI_BASE_URL,
api,
models: buildXaiCatalogModels(),
};
}
function buildXaiOAuthFallbackProvider(): ModelProviderConfig {
return {
baseUrl: XAI_GROK_OAUTH_BASE_URL,
api: "openai-responses",
auth: "oauth",
models: buildXaiCatalogModels(),
};
}
export async function buildLiveXaiProvider(params: {
apiKey?: string;
discoveryApiKey?: string;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
}): Promise<ModelProviderConfig> {
return await buildLiveModelProviderConfig({
providerId: PROVIDER_ID,
endpoint: XAI_MODELS_ENDPOINT,
providerConfig: {
baseUrl: XAI_BASE_URL,
api: "openai-responses",
},
models: buildXaiCatalogModels(),
apiKey: params.apiKey,
discoveryApiKey: params.discoveryApiKey,
fetchGuard: params.fetchGuard,
signal: params.signal,
ttlMs: XAI_MODELS_CACHE_TTL_MS,
auditContext: "xai-model-discovery",
});
}
function readLiveModelString(row: unknown, key: string): string | undefined {
if (!row || typeof row !== "object" || Array.isArray(row)) {
return undefined;
}
const value = (row as Record<string, unknown>)[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function readLiveModelPositiveInteger(row: unknown, keys: readonly string[]): number | undefined {
if (!row || typeof row !== "object" || Array.isArray(row)) {
return undefined;
}
const record = row as Record<string, unknown>;
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) {
return value;
}
}
return undefined;
}
function readLiveModelBoolean(row: unknown, key: string): boolean | undefined {
if (!row || typeof row !== "object" || Array.isArray(row)) {
return undefined;
}
const value = (row as Record<string, unknown>)[key];
return typeof value === "boolean" ? value : undefined;
}
function resolveXaiOauthMetadataFallback(modelId: string) {
if (modelId === "grok-build") {
return resolveXaiCatalogEntry("grok-build-0.1");
}
return resolveXaiCatalogEntry(modelId);
}
function isXaiOAuthResponsesModel(row: unknown, fallback: ModelDefinitionConfig | undefined) {
const modelId = readLiveModelString(row, "id") ?? readLiveModelString(row, "model");
if (modelId && (XAI_IMAGE_MODELS as readonly string[]).includes(modelId)) {
return false;
}
const backend =
readLiveModelString(row, "api_backend") ??
readLiveModelString(row, "apiBackend") ??
readLiveModelString(row, "backend");
if (backend) {
const normalizedBackend = backend.toLowerCase();
return (
normalizedBackend === "responses" ||
normalizedBackend === "chat" ||
normalizedBackend === "language"
);
}
return Boolean(fallback);
}
function buildXaiOauthModelFromLiveRow(row: unknown): ModelDefinitionConfig | undefined {
const modelId = readLiveModelString(row, "id") ?? readLiveModelString(row, "model");
if (!modelId) {
return undefined;
}
const fallback = resolveXaiOauthMetadataFallback(modelId);
if (!isXaiOAuthResponsesModel(row, fallback)) {
return undefined;
}
const contextWindow =
readLiveModelPositiveInteger(row, ["context_window", "contextWindow"]) ??
fallback?.contextWindow ??
XAI_DEFAULT_CONTEXT_WINDOW;
const maxTokens =
readLiveModelPositiveInteger(row, ["max_completion_tokens", "maxCompletionTokens"]) ??
fallback?.maxTokens ??
XAI_DEFAULT_MAX_TOKENS;
const supportsReasoningEffort =
readLiveModelBoolean(row, "supports_reasoning_effort") ??
readLiveModelBoolean(row, "supportsReasoningEffort");
const reasoning =
supportsReasoningEffort === true ||
fallback?.reasoning === true ||
XAI_GROK_OAUTH_REASONING_MODEL_IDS.has(modelId);
return {
id: modelId,
name: readLiveModelString(row, "name") ?? fallback?.name ?? modelId,
api: "openai-responses",
baseUrl: XAI_GROK_OAUTH_BASE_URL,
reasoning,
input: fallback?.input ?? ["text"],
cost: fallback?.cost ?? XAI_UNKNOWN_MODEL_COST,
contextWindow,
maxTokens,
...(fallback?.compat ? { compat: fallback.compat } : {}),
...(fallback?.thinkingLevelMap ? { thinkingLevelMap: fallback.thinkingLevelMap } : {}),
};
}
export async function buildLiveXaiOAuthProvider(params: {
discoveryApiKey: string;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
}): Promise<ModelProviderConfig> {
try {
const rows = await getCachedLiveProviderModelRows({
providerId: PROVIDER_ID,
endpoint: XAI_GROK_OAUTH_MODELS_ENDPOINT,
discoveryApiKey: params.discoveryApiKey,
fetchGuard: params.fetchGuard,
signal: params.signal,
ttlMs: XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS,
auditContext: "xai-grok-oauth-model-discovery",
cacheKeyParts: [
PROVIDER_ID,
"grok-oauth-model-rows",
XAI_GROK_OAUTH_MODELS_ENDPOINT,
params.discoveryApiKey,
],
});
const models = rows
.map(buildXaiOauthModelFromLiveRow)
.filter((model): model is ModelDefinitionConfig => Boolean(model));
if (models.length > 0) {
return {
baseUrl: XAI_GROK_OAUTH_BASE_URL,
api: "openai-responses",
auth: "oauth",
models,
};
}
} catch {
// Grok subscription discovery is advisory. If the proxy is unavailable,
// preserve the OAuth proxy transport instead of publishing API-key rows.
}
return buildXaiOAuthFallbackProvider();
}

View File

@@ -0,0 +1,23 @@
// Xai API module exposes the plugin public contract.
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
export function createXaiProvider(): ProviderPlugin {
return {
id: "xai",
label: "xAI",
aliases: ["x-ai"],
docsPath: "/providers/xai",
auth: [
{
id: "api-key",
kind: "api_key",
label: "xAI API key",
hint: "API key",
run: async () => ({ profiles: [] }),
wizard: {
groupLabel: "xAI (Grok)",
},
},
],
};
}

View File

@@ -0,0 +1,28 @@
// Xai provider module implements model/runtime integration.
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { readProviderEnvValue } from "openclaw/plugin-sdk/provider-web-search";
import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js";
const PROVIDER_ID = "xai";
function resolveXaiSyntheticAuth(config: unknown) {
const apiKey =
resolveFallbackXaiAuth(config as never)?.apiKey || readProviderEnvValue(["XAI_API_KEY"]);
return apiKey
? {
apiKey,
source: "xAI plugin config",
mode: "api-key" as const,
}
: undefined;
}
const xaiProviderDiscovery: ProviderPlugin = {
id: PROVIDER_ID,
label: "xAI",
docsPath: "/providers/models",
auth: [],
resolveSyntheticAuth: ({ config }) => resolveXaiSyntheticAuth(config),
};
export default xaiProviderDiscovery;

View File

@@ -0,0 +1,46 @@
// Xai provider module implements model/runtime integration.
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveXaiCatalogEntry, XAI_BASE_URL } from "./model-definitions.js";
import { normalizeXaiModelId } from "./model-id.js";
import { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js";
const XAI_MODERN_MODEL_PREFIXES = ["grok-build-0.1", "grok-4.3", "grok-4.20"] as const;
export function isModernXaiModel(modelId: string): boolean {
const normalized = normalizeXaiModelId(modelId.trim());
const lower = normalizeOptionalLowercaseString(normalized) ?? "";
if (!lower || lower.includes("multi-agent")) {
return false;
}
return XAI_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix));
}
export function resolveXaiForwardCompatModel(params: {
providerId: string;
ctx: ProviderResolveDynamicModelContext;
}) {
const definition = resolveXaiCatalogEntry(params.ctx.modelId);
if (!definition) {
return undefined;
}
return applyXaiRuntimeModelCompat(
normalizeModelCompat({
id: definition.id,
name: definition.name,
api: params.ctx.providerConfig?.api ?? "openai-responses",
provider: params.providerId,
baseUrl: params.ctx.providerConfig?.baseUrl ?? XAI_BASE_URL,
reasoning: definition.reasoning,
input: definition.input,
cost: definition.cost,
contextWindow: definition.contextWindow,
maxTokens: definition.maxTokens,
} as ProviderRuntimeModel),
);
}

View File

@@ -0,0 +1,38 @@
// Xai tests cover provider policy api plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveThinkingProfile } from "./provider-policy-api.js";
describe("xai provider thinking policy", () => {
it("exposes thinking levels for reasoning-capable xAI models", () => {
const profile = resolveThinkingProfile({
provider: "xai",
modelId: "grok-4.3",
});
expect(profile.defaultLevel).toBe("low");
expect(profile.levels.map((level) => level.id)).toEqual([
"off",
"minimal",
"low",
"medium",
"high",
]);
});
it("keeps non-reasoning and non-xai routes off-only", () => {
expect(
resolveThinkingProfile({
provider: "xai",
modelId: "grok-4-fast-non-reasoning",
reasoning: false,
}),
).toEqual({ levels: [{ id: "off" }], defaultLevel: "off" });
expect(
resolveThinkingProfile({
provider: "openrouter",
modelId: "x-ai/grok-4.3",
reasoning: true,
}),
).toEqual({ levels: [{ id: "off" }], defaultLevel: "off" });
});
});

View File

@@ -0,0 +1,19 @@
// Xai API module exposes the plugin public contract.
import type {
ProviderDefaultThinkingPolicyContext,
ProviderThinkingProfile,
} from "openclaw/plugin-sdk/plugin-entry";
import { resolveXaiCatalogEntry } from "./model-definitions.js";
export function resolveThinkingProfile(
ctx: ProviderDefaultThinkingPolicyContext,
): ProviderThinkingProfile {
const reasoning = ctx.reasoning ?? resolveXaiCatalogEntry(ctx.modelId)?.reasoning;
if (ctx.provider !== "xai" || !reasoning) {
return { levels: [{ id: "off" }], defaultLevel: "off" };
}
return {
levels: [{ id: "off" }, { id: "minimal" }, { id: "low" }, { id: "medium" }, { id: "high" }],
defaultLevel: "low",
};
}

View File

@@ -0,0 +1,280 @@
// Xai tests cover realtime transcription provider plugin behavior.
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import type WebSocket from "ws";
import { WebSocketServer } from "ws";
import { buildXaiRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
const { isProviderAuthProfileConfiguredMock, resolveApiKeyForProviderMock } = vi.hoisted(() => ({
isProviderAuthProfileConfiguredMock: vi.fn(() => false),
resolveApiKeyForProviderMock: vi.fn(
async (): Promise<{ apiKey: string | undefined }> => ({ apiKey: undefined }),
),
}));
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
isProviderAuthProfileConfigured: isProviderAuthProfileConfiguredMock,
}));
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
let cleanup: (() => Promise<void>) | undefined;
afterEach(async () => {
await cleanup?.();
cleanup = undefined;
isProviderAuthProfileConfiguredMock.mockReset();
isProviderAuthProfileConfiguredMock.mockReturnValue(false);
resolveApiKeyForProviderMock.mockReset();
resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: undefined });
delete process.env.XAI_API_KEY;
vi.unstubAllEnvs();
});
async function createRealtimeSttServer(params?: {
onRequest?: (url: URL, headers: Record<string, string | string[] | undefined>) => void;
onBinary?: (audio: Buffer) => void;
initialEvent?: unknown;
}) {
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
const clients = new Set<WebSocket>();
const done = vi.fn();
let resolveDone: (() => void) | undefined;
const donePromise = new Promise<void>((resolve) => {
resolveDone = resolve;
});
server.on("upgrade", (request, socket, head) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
params?.onRequest?.(url, request.headers);
wss.handleUpgrade(request, socket, head, (ws) => {
clients.add(ws);
ws.on("close", () => clients.delete(ws));
ws.send(JSON.stringify(params?.initialEvent ?? { type: "transcript.created" }));
ws.on("message", (data, isBinary) => {
const buffer = Buffer.isBuffer(data)
? data
: Array.isArray(data)
? Buffer.concat(data)
: Buffer.from(data);
if (isBinary) {
params?.onBinary?.(buffer);
ws.send(
JSON.stringify({
type: "transcript.partial",
text: "hello openclaw",
is_final: false,
speech_final: false,
}),
);
ws.send(
JSON.stringify({
type: "transcript.partial",
text: "hello openclaw final",
is_final: true,
speech_final: true,
}),
);
return;
}
const event = JSON.parse(buffer.toString()) as { type?: string };
if (event.type === "audio.done") {
ws.send(JSON.stringify({ type: "transcript.done", text: "hello openclaw final" }));
done();
resolveDone?.();
}
});
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const port = (server.address() as AddressInfo).port;
cleanup = async () => {
for (const ws of clients) {
ws.terminate();
}
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
};
return { baseUrl: `http://127.0.0.1:${port}/v1`, done, donePromise };
}
function requireFirstErrorArg(mock: ReturnType<typeof vi.fn>, label: string): Error {
const [call] = mock.mock.calls;
if (!call || !(call[0] instanceof Error)) {
throw new Error(`expected ${label}`);
}
return call[0];
}
describe("xai realtime transcription provider", () => {
it("normalizes provider config for voice-call streaming", () => {
const provider = buildXaiRealtimeTranscriptionProvider();
expect(
provider.resolveConfig?.({
cfg: {} as never,
rawConfig: {
providers: {
xai: {
apiKey: "xai-test-key",
baseUrl: "https://api.x.ai/v1",
sampleRate: 24000,
encoding: "pcm",
interimResults: false,
endpointingMs: 500,
language: "en",
},
},
},
}),
).toEqual({
apiKey: "xai-test-key",
baseUrl: "https://api.x.ai/v1",
sampleRate: 24000,
encoding: "pcm",
interimResults: false,
endpointingMs: 500,
language: "en",
});
});
it("streams raw binary audio and maps partial and final transcript events", async () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
const binaryFrames: Buffer[] = [];
const requestUrls: URL[] = [];
const upgradeHeaders: Array<Record<string, string | string[] | undefined>> = [];
const server = await createRealtimeSttServer({
onRequest: (url, headers) => {
requestUrls.push(url);
upgradeHeaders.push(headers);
},
onBinary: (audio) => binaryFrames.push(audio),
});
const provider = buildXaiRealtimeTranscriptionProvider();
const onPartial = vi.fn();
let resolveFinalTranscript: (() => void) | undefined;
const finalTranscript = new Promise<void>((resolve) => {
resolveFinalTranscript = resolve;
});
const onTranscript = vi.fn((text: string) => {
if (text === "hello openclaw final") {
resolveFinalTranscript?.();
}
});
const onSpeechStart = vi.fn();
const session = provider.createSession({
providerConfig: {
apiKey: "xai-test-key",
baseUrl: server.baseUrl,
sampleRate: 24000,
encoding: "pcm",
endpointingMs: 500,
},
onPartial,
onTranscript,
onSpeechStart,
});
session.sendAudio(Buffer.from("queued-before-ready"));
await session.connect();
session.sendAudio(Buffer.from("after-ready"));
await finalTranscript;
session.close();
await server.donePromise;
expect(requestUrls[0]?.pathname).toBe("/v1/stt");
expect(requestUrls[0]?.searchParams.get("sample_rate")).toBe("24000");
expect(requestUrls[0]?.searchParams.get("encoding")).toBe("pcm");
expect(requestUrls[0]?.searchParams.get("interim_results")).toBe("true");
expect(requestUrls[0]?.searchParams.get("endpointing")).toBe("500");
expect(upgradeHeaders[0]?.["user-agent"]).toBeUndefined();
expect(upgradeHeaders[0]?.authorization).toBe("Bearer xai-test-key");
expect(Buffer.concat(binaryFrames).toString()).toContain("queued-before-ready");
expect(Buffer.concat(binaryFrames).toString()).toContain("after-ready");
expect(onSpeechStart).toHaveBeenCalled();
expect(onPartial).toHaveBeenCalledWith("hello openclaw");
vi.unstubAllEnvs();
});
it("rejects setup errors before the stream is ready", async () => {
const server = await createRealtimeSttServer({
initialEvent: {
type: "error",
error: {
message: "Streaming ASR unavailable",
},
},
});
const provider = buildXaiRealtimeTranscriptionProvider();
const onError = vi.fn();
const session = provider.createSession({
providerConfig: {
apiKey: "xai-test-key",
baseUrl: server.baseUrl,
},
onError,
});
await expect(session.connect()).rejects.toThrow("Streaming ASR unavailable");
expect(session.isConnected()).toBe(false);
const error = requireFirstErrorArg(onError, "xAI realtime setup error callback");
expect(error.message).toBe("Streaming ASR unavailable");
});
it("accepts xAI realtime aliases", () => {
const provider = buildXaiRealtimeTranscriptionProvider();
expect(provider.aliases).toContain("xai-realtime");
expect(provider.aliases).toContain("grok-stt-streaming");
});
it("reports configured when an xAI auth profile exists, even without env or config apiKey", () => {
delete process.env.XAI_API_KEY;
isProviderAuthProfileConfiguredMock.mockReturnValue(true);
const provider = buildXaiRealtimeTranscriptionProvider();
expect(provider.isConfigured({ cfg: {}, providerConfig: {} })).toBe(true);
expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith({
provider: "xai",
cfg: {},
});
});
it("threads cfg into the lazy WebSocket bearer resolver", async () => {
delete process.env.XAI_API_KEY;
resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: "oauth-bearer" });
const upgradeHeaders: Array<Record<string, string | string[] | undefined>> = [];
const server = await createRealtimeSttServer({
onRequest: (_url, headers) => {
upgradeHeaders.push(headers);
},
});
const provider = buildXaiRealtimeTranscriptionProvider();
const cfg = { agents: { defaults: {} } };
const session = provider.createSession({
cfg,
providerConfig: {
baseUrl: server.baseUrl,
},
});
await session.connect();
session.close();
await server.donePromise;
expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({ provider: "xai", cfg });
expect(upgradeHeaders[0]?.authorization).toBe("Bearer oauth-bearer");
});
});

View File

@@ -0,0 +1,283 @@
// Xai provider module implements model/runtime integration.
import {
isProviderAuthProfileConfigured,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
createRealtimeTranscriptionWebSocketSession,
type RealtimeTranscriptionProviderConfig,
type RealtimeTranscriptionProviderPlugin,
type RealtimeTranscriptionSession,
type RealtimeTranscriptionSessionCreateRequest,
type RealtimeTranscriptionWebSocketTransport,
} from "openclaw/plugin-sdk/realtime-transcription";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import {
normalizeOptionalString,
parseBooleanValue as readBoolean,
parseFiniteNumber as readFiniteNumber,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { XAI_BASE_URL } from "./model-definitions.js";
import { xaiUserAgentHeaderFor } from "./src/xai-user-agent.js";
type XaiRealtimeTranscriptionEncoding = "pcm" | "mulaw" | "alaw";
type XaiRealtimeTranscriptionProviderConfig = {
apiKey?: string;
baseUrl?: string;
sampleRate?: number;
encoding?: XaiRealtimeTranscriptionEncoding;
interimResults?: boolean;
endpointingMs?: number;
language?: string;
};
type XaiRealtimeTranscriptionSessionConfig = RealtimeTranscriptionSessionCreateRequest & {
apiKey: string;
// Late-bound bearer; called per (re)connect.
resolveApiKey?: () => Promise<string>;
baseUrl: string;
sampleRate: number;
encoding: XaiRealtimeTranscriptionEncoding;
interimResults: boolean;
endpointingMs: number;
language?: string;
};
type XaiRealtimeTranscriptionEvent = {
type?: string;
text?: string;
transcript?: string;
is_final?: boolean;
speech_final?: boolean;
error?: unknown;
message?: string;
};
const XAI_REALTIME_STT_DEFAULT_SAMPLE_RATE = 8000;
const XAI_REALTIME_STT_DEFAULT_ENCODING: XaiRealtimeTranscriptionEncoding = "mulaw";
const XAI_REALTIME_STT_DEFAULT_ENDPOINTING_MS = 800;
const XAI_REALTIME_STT_CONNECT_TIMEOUT_MS = 10_000;
const XAI_REALTIME_STT_CLOSE_TIMEOUT_MS = 5_000;
const XAI_REALTIME_STT_MAX_RECONNECT_ATTEMPTS = 5;
const XAI_REALTIME_STT_RECONNECT_DELAY_MS = 1000;
const XAI_REALTIME_STT_MAX_QUEUED_BYTES = 2 * 1024 * 1024;
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
function readNestedXaiConfig(rawConfig: RealtimeTranscriptionProviderConfig) {
const raw = readRecord(rawConfig);
const providers = readRecord(raw?.providers);
return readRecord(providers?.xai ?? raw?.xai ?? raw) ?? {};
}
function normalizeEncoding(value: unknown): XaiRealtimeTranscriptionEncoding | undefined {
const normalized = normalizeOptionalString(value)?.toLowerCase();
if (!normalized) {
return undefined;
}
if (normalized === "ulaw" || normalized === "g711_ulaw" || normalized === "g711-mulaw") {
return "mulaw";
}
if (normalized === "g711_alaw" || normalized === "g711-alaw") {
return "alaw";
}
if (normalized === "pcm" || normalized === "mulaw" || normalized === "alaw") {
return normalized;
}
throw new Error(`Invalid xAI realtime transcription encoding: ${normalized}`);
}
function normalizeXaiRealtimeBaseUrl(value?: string): string {
return normalizeOptionalString(value ?? process.env.XAI_BASE_URL) ?? XAI_BASE_URL;
}
function toXaiRealtimeWsUrl(config: XaiRealtimeTranscriptionSessionConfig): string {
const url = new URL(normalizeXaiRealtimeBaseUrl(config.baseUrl));
url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
url.pathname = `${url.pathname.replace(/\/+$/, "")}/stt`;
url.searchParams.set("sample_rate", String(config.sampleRate));
url.searchParams.set("encoding", config.encoding);
url.searchParams.set("interim_results", String(config.interimResults));
url.searchParams.set("endpointing", String(config.endpointingMs));
if (config.language) {
url.searchParams.set("language", config.language);
}
return url.toString();
}
function normalizeProviderConfig(
config: RealtimeTranscriptionProviderConfig,
): XaiRealtimeTranscriptionProviderConfig {
const raw = readNestedXaiConfig(config);
return {
apiKey: normalizeResolvedSecretInputString({
value: raw.apiKey,
path: "plugins.entries.voice-call.config.streaming.providers.xai.apiKey",
}),
baseUrl: normalizeOptionalString(raw.baseUrl),
sampleRate: readFiniteNumber(raw.sampleRate ?? raw.sample_rate),
encoding: normalizeEncoding(raw.encoding),
interimResults: readBoolean(raw.interimResults ?? raw.interim_results),
endpointingMs: readFiniteNumber(raw.endpointingMs ?? raw.endpointing ?? raw.silenceDurationMs),
language: normalizeOptionalString(raw.language),
};
}
function readErrorDetail(value: unknown): string {
if (typeof value === "string") {
return value;
}
const record = readRecord(value);
const message = normalizeOptionalString(record?.message);
const code = normalizeOptionalString(record?.code);
return message ?? code ?? "xAI realtime transcription error";
}
function readTranscriptText(event: XaiRealtimeTranscriptionEvent): string | undefined {
return normalizeOptionalString(event.text ?? event.transcript);
}
function createXaiRealtimeTranscriptionSession(
config: XaiRealtimeTranscriptionSessionConfig,
): RealtimeTranscriptionSession {
let lastTranscript: string | undefined;
let speechStarted = false;
const emitTranscript = (text: string) => {
if (text === lastTranscript) {
return;
}
lastTranscript = text;
config.onTranscript?.(text);
};
const handleEvent = (
event: XaiRealtimeTranscriptionEvent,
transport: RealtimeTranscriptionWebSocketTransport,
) => {
if (event.type === "transcript.created") {
transport.markReady();
return;
}
if (!transport.isReady() && event.type === "error") {
transport.failConnect(new Error(readErrorDetail(event.error ?? event.message)));
return;
}
switch (event.type) {
case "transcript.partial": {
const text = readTranscriptText(event);
if (!text) {
return;
}
if (!speechStarted) {
speechStarted = true;
config.onSpeechStart?.();
}
if (event.is_final && event.speech_final) {
emitTranscript(text);
speechStarted = false;
return;
}
config.onPartial?.(text);
return;
}
case "transcript.done": {
const text = readTranscriptText(event);
if (text) {
emitTranscript(text);
}
transport.closeNow();
return;
}
case "error":
config.onError?.(new Error(readErrorDetail(event.error ?? event.message)));
default:
}
};
return createRealtimeTranscriptionWebSocketSession<XaiRealtimeTranscriptionEvent>({
providerId: "xai",
callbacks: config,
url: () => toXaiRealtimeWsUrl(config),
headers: async () => {
const apiKey = config.resolveApiKey ? await config.resolveApiKey() : config.apiKey;
return {
Authorization: `Bearer ${apiKey}`,
...xaiUserAgentHeaderFor(config.baseUrl),
};
},
connectTimeoutMs: XAI_REALTIME_STT_CONNECT_TIMEOUT_MS,
closeTimeoutMs: XAI_REALTIME_STT_CLOSE_TIMEOUT_MS,
maxReconnectAttempts: XAI_REALTIME_STT_MAX_RECONNECT_ATTEMPTS,
reconnectDelayMs: XAI_REALTIME_STT_RECONNECT_DELAY_MS,
maxQueuedBytes: XAI_REALTIME_STT_MAX_QUEUED_BYTES,
connectTimeoutMessage: "xAI realtime transcription connection timeout",
connectClosedBeforeReadyMessage: "xAI realtime transcription connection closed before ready",
reconnectLimitMessage: "xAI realtime transcription reconnect limit reached",
sendAudio: (audio, transport) => {
transport.sendBinary(audio);
},
onClose: (transport) => {
transport.sendJson({ type: "audio.done" });
},
onMessage: handleEvent,
});
}
export function buildXaiRealtimeTranscriptionProvider(): RealtimeTranscriptionProviderPlugin {
return {
id: "xai",
label: "xAI Realtime Transcription",
aliases: ["xai-realtime", "grok-stt-streaming"],
autoSelectOrder: 25,
resolveConfig: ({ rawConfig }) => normalizeProviderConfig(rawConfig),
isConfigured: ({ providerConfig, cfg }) =>
Boolean(normalizeProviderConfig(providerConfig).apiKey || process.env.XAI_API_KEY) ||
isProviderAuthProfileConfigured({ provider: "xai", cfg }),
createSession: (req) => {
const config = normalizeProviderConfig(req.providerConfig);
// createSession must stay sync per RealtimeTranscriptionProviderPlugin; bearer is resolved lazily in headers().
const seedApiKey =
normalizeOptionalString(config.apiKey) ?? normalizeOptionalString(process.env.XAI_API_KEY);
return createXaiRealtimeTranscriptionSession({
...req,
apiKey: seedApiKey ?? "",
resolveApiKey: () => resolveXaiRealtimeApiKey(config.apiKey, req.cfg),
baseUrl: normalizeXaiRealtimeBaseUrl(config.baseUrl),
sampleRate: config.sampleRate ?? XAI_REALTIME_STT_DEFAULT_SAMPLE_RATE,
encoding: config.encoding ?? XAI_REALTIME_STT_DEFAULT_ENCODING,
interimResults: config.interimResults ?? true,
endpointingMs: config.endpointingMs ?? XAI_REALTIME_STT_DEFAULT_ENDPOINTING_MS,
language: config.language,
});
},
};
}
// Resolve an xAI bearer for the realtime `/stt` WebSocket:
// 1. Configured `plugins.entries.voice-call.config.streaming.providers.xai.apiKey`
// 2. `XAI_API_KEY` env var
// 3. xAI OAuth auth profile (cfg-scoped)
async function resolveXaiRealtimeApiKey(
configApiKey: string | undefined,
cfg: OpenClawConfig | undefined,
): Promise<string> {
const direct =
normalizeOptionalString(configApiKey) ?? normalizeOptionalString(process.env.XAI_API_KEY);
if (direct) {
return direct;
}
const auth = await resolveApiKeyForProvider({ provider: "xai", cfg });
const oauthKey = normalizeOptionalString(auth?.apiKey);
if (oauthKey) {
return oauthKey;
}
throw new Error(
"xAI credentials missing for realtime STT. Sign in with `openclaw onboard --auth-choice xai-oauth`, or run `openclaw onboard --auth-choice xai-api-key`, or set XAI_API_KEY.",
);
}

View File

@@ -0,0 +1,61 @@
// Xai tests cover runtime model compat plugin behavior.
import { describe, expect, it } from "vitest";
import { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js";
describe("xai runtime model compat", () => {
it("maps OpenClaw thinking levels to xAI efforts for reasoning-capable models", () => {
const model = applyXaiRuntimeModelCompat({
id: "grok-4.3",
provider: "xai",
reasoning: true,
});
expect(model.compat).toMatchObject({
supportsReasoningEffort: true,
supportedReasoningEfforts: ["low", "medium", "high"],
});
expect(model.thinkingLevelMap).toEqual({
off: null,
minimal: "low",
low: "low",
medium: "medium",
high: "high",
xhigh: "high",
});
});
it("suppresses reasoning efforts for non-reasoning models", () => {
const model = applyXaiRuntimeModelCompat({
id: "grok-4-fast-non-reasoning",
provider: "xai",
reasoning: false,
});
expect(model.thinkingLevelMap).toEqual({
off: null,
minimal: null,
low: null,
medium: null,
high: null,
xhigh: null,
});
});
it("does not advertise configurable reasoning effort for older xAI reasoning models", () => {
const model = applyXaiRuntimeModelCompat({
id: "grok-4.20-beta-latest-reasoning",
provider: "xai",
reasoning: true,
});
expect(model.compat).toMatchObject({ supportsReasoningEffort: false });
expect(model.thinkingLevelMap).toEqual({
off: null,
minimal: null,
low: null,
medium: null,
high: null,
xhigh: null,
});
});
});

View File

@@ -0,0 +1,75 @@
// Xai plugin module implements runtime model compat behavior.
// Reasoning effort is configurable only for grok-4.3*; encrypted reasoning include/replay is
// handled separately in stream.ts for all reasoning-capable xAI models.
import { applyXaiModelCompat } from "./model-compat.js";
type XaiRuntimeModelCompat = {
compat?: unknown;
id?: unknown;
reasoning?: unknown;
thinkingLevelMap?: XaiThinkingLevelMap;
};
type XaiThinkingLevelMap = Partial<
Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh", string | null>
>;
const XAI_UNSUPPORTED_REASONING_EFFORTS = {
off: null,
minimal: null,
low: null,
medium: null,
high: null,
xhigh: null,
} satisfies NonNullable<XaiRuntimeModelCompat["thinkingLevelMap"]>;
const XAI_REASONING_EFFORTS = {
off: null,
minimal: "low",
low: "low",
medium: "medium",
high: "high",
xhigh: "high",
} satisfies NonNullable<XaiRuntimeModelCompat["thinkingLevelMap"]>;
const XAI_SUPPORTED_REASONING_EFFORTS = ["low", "medium", "high"] as const;
function normalizeXaiCompatModelId(id: unknown): string {
return typeof id === "string" ? id.trim().toLowerCase() : "";
}
function supportsConfigurableXaiReasoningEffort(model: XaiRuntimeModelCompat): boolean {
const id = normalizeXaiCompatModelId(model.id);
return model.reasoning === true && (id === "grok-4.3" || id.startsWith("grok-4.3-"));
}
function resolveXaiReasoningEffortCompat(model: XaiRuntimeModelCompat): Record<string, unknown> {
if (supportsConfigurableXaiReasoningEffort(model)) {
return {
supportsReasoningEffort: true,
supportedReasoningEfforts: [...XAI_SUPPORTED_REASONING_EFFORTS],
};
}
return { supportsReasoningEffort: false };
}
export function applyXaiRuntimeModelCompat<T extends XaiRuntimeModelCompat>(
model: T,
): T & { compat: Record<string, unknown>; thinkingLevelMap: XaiThinkingLevelMap } {
const withCompat = applyXaiModelCompat(model);
const supportsReasoningEffort = supportsConfigurableXaiReasoningEffort(withCompat);
const existingCompat =
withCompat.compat && typeof withCompat.compat === "object"
? (withCompat.compat as Record<string, unknown>)
: {};
return {
...withCompat,
compat: {
...existingCompat,
...resolveXaiReasoningEffortCompat(withCompat),
},
thinkingLevelMap: {
...withCompat.thinkingLevelMap,
...(supportsReasoningEffort ? XAI_REASONING_EFFORTS : XAI_UNSUPPORTED_REASONING_EFFORTS),
},
};
}

View File

@@ -0,0 +1,23 @@
// Xai API module exposes the plugin public contract.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { isRecord } from "./src/tool-config-shared.js";
export default definePluginEntry({
id: "xai",
name: "xAI Setup",
description: "Lightweight xAI setup hooks",
register(api) {
api.registerAutoEnableProbe(({ config }) => {
const pluginConfig = config.plugins?.entries?.xai?.config;
const web = config.tools?.web as Record<string, unknown> | undefined;
if (
isRecord(web?.x_search) ||
(isRecord(pluginConfig) &&
(isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution)))
) {
return "xai tool configured";
}
return null;
});
},
});

View File

@@ -0,0 +1,213 @@
// Xai tests cover speech provider plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildXaiSpeechProvider } from "./speech-provider.js";
const { xaiTTSMock, isProviderAuthProfileConfiguredMock, resolveApiKeyForProviderMock } =
vi.hoisted(() => ({
xaiTTSMock: vi.fn(async () => Buffer.from("audio-bytes")),
isProviderAuthProfileConfiguredMock: vi.fn(() => false),
resolveApiKeyForProviderMock: vi.fn(
async (): Promise<{ apiKey: string | undefined }> => ({ apiKey: undefined }),
),
}));
vi.mock("./tts.js", () => ({
XAI_BASE_URL: "https://api.x.ai/v1",
XAI_TTS_VOICES: ["eve", "ara", "rex", "sal", "leo", "una"],
isValidXaiTtsVoice: (voice: string) => ["eve", "ara", "rex", "sal", "leo", "una"].includes(voice),
normalizeXaiLanguageCode: (value: unknown) =>
typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined,
normalizeXaiTtsBaseUrl: (baseUrl?: string) =>
baseUrl?.trim().replace(/\/+$/, "") || "https://api.x.ai/v1",
xaiTTS: xaiTTSMock,
}));
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
isProviderAuthProfileConfigured: isProviderAuthProfileConfiguredMock,
}));
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
function requireLastTtsCall(): {
text?: string;
apiKey?: string;
baseUrl?: string;
voiceId?: string;
language?: string;
speed?: number;
responseFormat?: string;
maxBytes?: number;
} {
const params = (xaiTTSMock.mock.calls as unknown as Array<[unknown]>).at(-1)?.[0] as
| {
text?: string;
apiKey?: string;
baseUrl?: string;
voiceId?: string;
language?: string;
speed?: number;
responseFormat?: string;
maxBytes?: number;
}
| undefined;
if (!params) {
throw new Error("Expected xaiTTS call");
}
return params;
}
describe("xai speech provider", () => {
afterEach(() => {
isProviderAuthProfileConfiguredMock.mockReset();
isProviderAuthProfileConfiguredMock.mockReturnValue(false);
resolveApiKeyForProviderMock.mockReset();
resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: undefined });
delete process.env.XAI_API_KEY;
});
it("synthesizes mp3 audio and does not claim native voice-note compatibility", async () => {
const provider = buildXaiSpeechProvider();
const result = await provider.synthesize({
text: "hello",
cfg: {
agents: {
defaults: {
mediaMaxMb: 2,
},
},
},
providerConfig: {
apiKey: "xai-key",
voiceId: "eve",
},
target: "voice-note",
timeoutMs: 5_000,
});
expect(result.outputFormat).toBe("mp3");
expect(result.fileExtension).toBe(".mp3");
expect(result.voiceCompatible).toBe(false);
expect(result.audioBuffer.byteLength).toBeGreaterThan(0);
const tts = requireLastTtsCall();
expect(tts.text).toBe("hello");
expect(tts.apiKey).toBe("xai-key");
expect(tts.baseUrl).toBe("https://api.x.ai/v1");
expect(tts.voiceId).toBe("eve");
expect(tts.responseFormat).toBe("mp3");
expect(tts.maxBytes).toBe(2 * 1024 * 1024);
});
it("honors configured response formats", async () => {
const provider = buildXaiSpeechProvider();
const result = await provider.synthesize({
text: "hello",
cfg: {},
providerConfig: {
apiKey: "xai-key",
responseFormat: "wav",
},
target: "audio-file",
timeoutMs: 5_000,
});
expect(result.outputFormat).toBe("wav");
expect(result.fileExtension).toBe(".wav");
expect(requireLastTtsCall().responseFormat).toBe("wav");
});
it("honors voice, language, and speed overrides for telephony output", async () => {
const provider = buildXaiSpeechProvider();
const result = await provider.synthesizeTelephony?.({
text: "hello",
cfg: {},
providerConfig: {
apiKey: "xai-key",
baseUrl: "https://api.x.ai/v1",
voiceId: "eve",
language: "en",
speed: 1,
},
providerOverrides: {
voice: "aura",
language: "es",
speed: 1.2,
},
timeoutMs: 5_000,
});
expect(result).toEqual({
audioBuffer: Buffer.from("audio-bytes"),
outputFormat: "pcm",
sampleRate: 24_000,
});
const tts = requireLastTtsCall();
expect(tts.voiceId).toBe("aura");
expect(tts.language).toBe("es");
expect(tts.speed).toBe(1.2);
expect(tts.responseFormat).toBe("pcm");
});
it("drops malformed speed values before synthesis", async () => {
const provider = buildXaiSpeechProvider();
await provider.synthesize({
text: "hello",
cfg: {},
providerConfig: {
apiKey: "xai-key",
speed: 2,
},
providerOverrides: {
speed: 0.5,
},
target: "audio-file",
timeoutMs: 5_000,
});
expect(requireLastTtsCall().speed).toBeUndefined();
});
it("reports configured when an xAI auth profile exists, even without env or config apiKey", () => {
isProviderAuthProfileConfiguredMock.mockReturnValue(true);
const provider = buildXaiSpeechProvider();
expect(
provider.isConfigured({
cfg: {},
providerConfig: {},
timeoutMs: 5_000,
}),
).toBe(true);
expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith({
provider: "xai",
cfg: {},
});
});
it("reports not configured when there is no apiKey, env, or auth profile", () => {
isProviderAuthProfileConfiguredMock.mockReturnValue(false);
const provider = buildXaiSpeechProvider();
expect(
provider.isConfigured({
cfg: {},
providerConfig: {},
timeoutMs: 5_000,
}),
).toBe(false);
});
it("threads cfg into the OAuth fallback resolver when no direct apiKey is available", async () => {
resolveApiKeyForProviderMock.mockResolvedValueOnce({ apiKey: "oauth-bearer" });
const provider = buildXaiSpeechProvider();
const cfg = { agents: { defaults: {} } };
await provider.synthesize({
text: "hello",
cfg,
providerConfig: {},
target: "voice-note",
timeoutMs: 5_000,
});
expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({ provider: "xai", cfg });
expect(requireLastTtsCall().apiKey).toBe("oauth-bearer");
});
});

View File

@@ -0,0 +1,297 @@
// Xai provider module implements model/runtime integration.
import {
isProviderAuthProfileConfigured,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import {
trimToUndefined,
type SpeechDirectiveTokenParseContext,
type SpeechProviderConfig,
type SpeechProviderOverrides,
type SpeechProviderPlugin,
type SpeechSynthesisTarget,
} from "openclaw/plugin-sdk/speech";
import {
asFiniteNumberInRange,
normalizeLowercaseStringOrEmpty,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
isValidXaiTtsVoice,
normalizeXaiLanguageCode,
normalizeXaiTtsBaseUrl,
XAI_BASE_URL,
XAI_TTS_VOICES,
xaiTTS,
} from "./tts.js";
const XAI_SPEECH_RESPONSE_FORMATS = ["mp3", "wav", "pcm", "mulaw", "alaw"] as const;
const DEFAULT_GENERATED_AUDIO_MAX_BYTES = 16 * 1024 * 1024;
type XaiSpeechResponseFormat = (typeof XAI_SPEECH_RESPONSE_FORMATS)[number];
type XaiTtsProviderConfig = {
apiKey?: string;
baseUrl: string;
voiceId: string;
language?: string;
speed?: number;
responseFormat?: XaiSpeechResponseFormat;
};
type XaiTtsProviderOverrides = {
voiceId?: string;
language?: string;
speed?: number;
};
function normalizeXaiSpeechSpeed(value: unknown): number | undefined {
return asFiniteNumberInRange(value, { min: 0.7, max: 1.5 });
}
function normalizeXaiSpeechResponseFormat(value: unknown): XaiSpeechResponseFormat | undefined {
const next = normalizeLowercaseStringOrEmpty(value);
if (!next) {
return undefined;
}
if (XAI_SPEECH_RESPONSE_FORMATS.some((format) => format === next)) {
return next as XaiSpeechResponseFormat;
}
throw new Error(`Invalid xAI speech responseFormat: ${next}`);
}
function resolveSpeechResponseFormat(
_target: SpeechSynthesisTarget,
configuredFormat?: XaiSpeechResponseFormat,
): XaiSpeechResponseFormat {
if (configuredFormat) {
return configuredFormat;
}
return "mp3";
}
function responseFormatToFileExtension(
format: XaiSpeechResponseFormat,
): ".mp3" | ".pcm" | ".wav" | ".mulaw" | ".alaw" {
switch (format) {
case "wav":
return ".wav";
case "pcm":
return ".pcm";
case "mulaw":
return ".mulaw";
case "alaw":
return ".alaw";
default:
return ".mp3";
}
}
function normalizeXaiProviderConfig(rawConfig: Record<string, unknown>): XaiTtsProviderConfig {
const providers = rawConfig?.providers as Record<string, unknown> | undefined;
const xai = (providers?.xai ?? rawConfig?.xai ?? rawConfig) as Record<string, unknown>;
return {
apiKey: normalizeResolvedSecretInputString({
value: xai?.apiKey,
path: "messages.tts.providers.xai.apiKey",
}),
baseUrl: normalizeXaiTtsBaseUrl(
trimToUndefined(xai?.baseUrl) ?? trimToUndefined(process.env.XAI_BASE_URL) ?? XAI_BASE_URL,
),
voiceId: trimToUndefined(xai?.voiceId ?? xai?.voice) ?? "eve",
language: normalizeXaiLanguageCode(trimToUndefined(xai?.language ?? xai?.languageCode)),
speed: normalizeXaiSpeechSpeed(xai?.speed),
responseFormat: normalizeXaiSpeechResponseFormat(xai?.responseFormat),
};
}
function readXaiProviderConfig(config: SpeechProviderConfig): XaiTtsProviderConfig {
const normalized = normalizeXaiProviderConfig({});
return {
apiKey: trimToUndefined(config.apiKey) ?? normalized.apiKey,
baseUrl: trimToUndefined(config.baseUrl) ?? normalized.baseUrl,
voiceId: trimToUndefined(config.voiceId ?? config.voice) ?? normalized.voiceId,
language:
normalizeXaiLanguageCode(trimToUndefined(config.language ?? config.languageCode)) ??
normalized.language,
speed: normalizeXaiSpeechSpeed(config.speed) ?? normalized.speed,
responseFormat:
normalizeXaiSpeechResponseFormat(config.responseFormat) ?? normalized.responseFormat,
};
}
function readXaiOverrides(overrides: SpeechProviderOverrides | undefined): XaiTtsProviderOverrides {
if (!overrides) {
return {};
}
return {
voiceId: trimToUndefined(overrides.voiceId ?? overrides.voice),
language: normalizeXaiLanguageCode(trimToUndefined(overrides.language)),
speed: normalizeXaiSpeechSpeed(overrides.speed),
};
}
function resolveGeneratedAudioMaxBytes(req: {
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
}): number {
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * 1024 * 1024);
}
return DEFAULT_GENERATED_AUDIO_MAX_BYTES;
}
function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): {
handled: boolean;
overrides?: SpeechProviderOverrides;
warnings?: string[];
} {
const providerConfig = ctx.providerConfig as Record<string, unknown> | undefined;
const baseUrl = trimToUndefined(providerConfig?.baseUrl);
switch (ctx.key) {
case "voice":
case "voice_id":
case "voiceid":
case "xai_voice":
case "xaivoice":
if (!ctx.policy.allowVoice) {
return { handled: true };
}
if (!isValidXaiTtsVoice(ctx.value, baseUrl)) {
return { handled: true, warnings: [`invalid xAI voice "${ctx.value}"`] };
}
return { handled: true, overrides: { voiceId: ctx.value } };
default:
return { handled: false };
}
}
export function buildXaiSpeechProvider(): SpeechProviderPlugin {
return {
id: "xai",
label: "xAI",
autoSelectOrder: 25,
models: [],
voices: XAI_TTS_VOICES,
resolveConfig: ({ rawConfig }) => normalizeXaiProviderConfig(rawConfig),
parseDirectiveToken,
resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => {
const base = normalizeXaiProviderConfig(baseTtsConfig);
const responseFormat = normalizeXaiSpeechResponseFormat(talkProviderConfig.responseFormat);
return {
...base,
...(talkProviderConfig.apiKey === undefined
? {}
: {
apiKey: normalizeResolvedSecretInputString({
value: talkProviderConfig.apiKey,
path: "talk.providers.xai.apiKey",
}),
}),
...(trimToUndefined(talkProviderConfig.baseUrl) == null
? {}
: { baseUrl: normalizeXaiTtsBaseUrl(trimToUndefined(talkProviderConfig.baseUrl)) }),
...(trimToUndefined(talkProviderConfig.voiceId) == null
? {}
: { voiceId: trimToUndefined(talkProviderConfig.voiceId) }),
...(normalizeXaiLanguageCode(
trimToUndefined(talkProviderConfig.language ?? talkProviderConfig.languageCode),
) == null
? {}
: {
language: normalizeXaiLanguageCode(
trimToUndefined(talkProviderConfig.language ?? talkProviderConfig.languageCode),
),
}),
...(normalizeXaiSpeechSpeed(talkProviderConfig.speed) == null
? {}
: { speed: normalizeXaiSpeechSpeed(talkProviderConfig.speed) }),
...(responseFormat == null ? {} : { responseFormat }),
};
},
resolveTalkOverrides: ({ params }) => ({
...(trimToUndefined(params.voiceId ?? params.voice) == null
? {}
: { voiceId: trimToUndefined(params.voiceId ?? params.voice) }),
...(normalizeXaiLanguageCode(trimToUndefined(params.language ?? params.languageCode)) == null
? {}
: {
language: normalizeXaiLanguageCode(
trimToUndefined(params.language ?? params.languageCode),
),
}),
...(normalizeXaiSpeechSpeed(params.speed) == null
? {}
: { speed: normalizeXaiSpeechSpeed(params.speed) }),
}),
listVoices: async () => XAI_TTS_VOICES.map((voice) => ({ id: voice, name: voice })),
isConfigured: ({ providerConfig, cfg }) =>
Boolean(readXaiProviderConfig(providerConfig).apiKey || process.env.XAI_API_KEY) ||
isProviderAuthProfileConfigured({ provider: "xai", cfg }),
synthesize: async (req) => {
const config = readXaiProviderConfig(req.providerConfig);
const overrides = readXaiOverrides(req.providerOverrides);
const apiKey = await resolveXaiAudioApiKey(config.apiKey, req.cfg);
const responseFormat = resolveSpeechResponseFormat(req.target, config.responseFormat);
const audioBuffer = await xaiTTS({
text: req.text,
apiKey,
baseUrl: config.baseUrl,
voiceId: overrides.voiceId ?? config.voiceId,
language: overrides.language ?? config.language,
speed: overrides.speed ?? config.speed,
responseFormat,
timeoutMs: req.timeoutMs,
maxBytes: resolveGeneratedAudioMaxBytes(req),
});
return {
audioBuffer,
outputFormat: responseFormat,
fileExtension: responseFormatToFileExtension(responseFormat),
voiceCompatible: false,
};
},
synthesizeTelephony: async (req) => {
const config = readXaiProviderConfig(req.providerConfig);
const overrides = readXaiOverrides(req.providerOverrides);
const apiKey = await resolveXaiAudioApiKey(config.apiKey, req.cfg);
const outputFormat = "pcm" as const;
const sampleRate = 24000;
const audioBuffer = await xaiTTS({
text: req.text,
apiKey,
baseUrl: config.baseUrl,
voiceId: overrides.voiceId ?? config.voiceId,
language: overrides.language ?? config.language,
speed: overrides.speed ?? config.speed,
responseFormat: outputFormat,
timeoutMs: req.timeoutMs,
maxBytes: resolveGeneratedAudioMaxBytes(req),
});
return { audioBuffer, outputFormat, sampleRate };
},
};
}
// Resolve an xAI bearer for `/v1/tts`:
// 1. Configured `messages.tts.providers.xai.apiKey` (or talk equivalent)
// 2. `XAI_API_KEY` env var
// 3. xAI OAuth auth profile (cfg-scoped)
async function resolveXaiAudioApiKey(
configApiKey: string | undefined,
cfg: OpenClawConfig,
): Promise<string> {
const direct = trimToUndefined(configApiKey) ?? trimToUndefined(process.env.XAI_API_KEY);
if (direct) {
return direct;
}
const auth = await resolveApiKeyForProvider({ provider: "xai", cfg });
const oauthKey = trimToUndefined(auth?.apiKey);
if (oauthKey) {
return oauthKey;
}
throw new Error(
"xAI credentials missing for TTS. Sign in with `openclaw onboard --auth-choice xai-oauth`, or run `openclaw onboard --auth-choice xai-api-key`, or set XAI_API_KEY.",
);
}

View File

@@ -0,0 +1,56 @@
// Xai helper module supports code execution config behavior.
import { isXaiToolEnabled, type XaiToolAuthContext } from "./tool-auth-shared.js";
export type CodeExecutionConfig = {
enabled?: boolean;
model?: string;
maxTurns?: number;
timeoutSeconds?: number;
};
export function readCodeExecutionConfigRecord(
config?: CodeExecutionConfig,
): Record<string, unknown> | undefined {
return config && typeof config === "object" ? (config as Record<string, unknown>) : undefined;
}
export function readPluginCodeExecutionConfig(cfg?: unknown): CodeExecutionConfig | undefined {
if (!cfg || typeof cfg !== "object") {
return undefined;
}
const entries = (cfg as Record<string, unknown>).plugins;
const pluginEntries =
entries && typeof entries === "object"
? ((entries as Record<string, unknown>).entries as Record<string, unknown> | undefined)
: undefined;
if (!pluginEntries) {
return undefined;
}
const xaiEntry = pluginEntries.xai;
if (!xaiEntry || typeof xaiEntry !== "object") {
return undefined;
}
const config = (xaiEntry as Record<string, unknown>).config;
if (!config || typeof config !== "object") {
return undefined;
}
const codeExecution = (config as Record<string, unknown>).codeExecution;
if (!codeExecution || typeof codeExecution !== "object") {
return undefined;
}
return codeExecution as CodeExecutionConfig;
}
export function resolveCodeExecutionEnabled(params: {
sourceConfig?: unknown;
runtimeConfig?: unknown;
config?: CodeExecutionConfig;
auth?: XaiToolAuthContext;
}): boolean {
return isXaiToolEnabled({
enabled: readCodeExecutionConfigRecord(params.config)?.enabled as boolean | undefined,
runtimeConfig: params.runtimeConfig as never,
sourceConfig: params.sourceConfig as never,
auth: params.auth,
});
}

View File

@@ -0,0 +1,111 @@
// Xai plugin module implements code execution shared behavior.
import { readProviderJsonObjectResponse } from "openclaw/plugin-sdk/provider-http";
import { postTrustedWebToolsJson } from "openclaw/plugin-sdk/provider-web-search";
import {
buildXaiResponsesToolBody,
requireXaiResponseTextAndCitations,
XAI_RESPONSES_ENDPOINT,
} from "./responses-tool-shared.js";
import {
resolveNormalizedXaiToolModel,
resolvePositiveIntegerToolConfig,
} from "./tool-config-shared.js";
import type { XaiWebSearchResponse } from "./web-search-shared.js";
const XAI_CODE_EXECUTION_ENDPOINT = XAI_RESPONSES_ENDPOINT;
const XAI_DEFAULT_CODE_EXECUTION_MODEL = "grok-4-1-fast";
type XaiCodeExecutionResponse = XaiWebSearchResponse & {
output?: Array<{
type?: string;
}>;
};
type XaiCodeExecutionResult = {
content: string;
citations: string[];
usedCodeExecution: boolean;
outputTypes: string[];
};
export function resolveXaiCodeExecutionModel(config?: Record<string, unknown>): string {
return resolveNormalizedXaiToolModel({
config,
defaultModel: XAI_DEFAULT_CODE_EXECUTION_MODEL,
});
}
export function resolveXaiCodeExecutionMaxTurns(
config?: Record<string, unknown>,
): number | undefined {
return resolvePositiveIntegerToolConfig(config, "maxTurns");
}
export function buildXaiCodeExecutionPayload(params: {
task: string;
model: string;
tookMs: number;
content: string;
citations: string[];
usedCodeExecution: boolean;
outputTypes: string[];
}): Record<string, unknown> {
return {
task: params.task,
provider: "xai",
model: params.model,
tookMs: params.tookMs,
content: params.content,
citations: params.citations,
usedCodeExecution: params.usedCodeExecution,
outputTypes: params.outputTypes,
};
}
export async function requestXaiCodeExecution(params: {
apiKey: string;
model: string;
timeoutSeconds: number;
maxTurns?: number;
task: string;
}): Promise<XaiCodeExecutionResult> {
return await postTrustedWebToolsJson(
{
url: XAI_CODE_EXECUTION_ENDPOINT,
timeoutSeconds: params.timeoutSeconds,
apiKey: params.apiKey,
body: buildXaiResponsesToolBody({
model: params.model,
inputText: params.task,
tools: [{ type: "code_interpreter" }],
maxTurns: params.maxTurns,
}),
errorLabel: "xAI",
},
async (response) => {
const data = (await readProviderJsonObjectResponse(
response,
"xAI code execution failed",
)) as XaiCodeExecutionResponse;
const { content, citations } = requireXaiResponseTextAndCitations(
data,
"xAI code execution failed",
);
const outputTypes = Array.isArray(data.output)
? [
...new Set(
data.output
.map((entry) => entry?.type)
.filter((value): value is string => Boolean(value)),
),
]
: [];
return {
content,
citations,
usedCodeExecution: outputTypes.includes("code_interpreter_call"),
outputTypes,
};
},
);
}

View File

@@ -0,0 +1,108 @@
// Xai tests cover responses tool shared plugin behavior.
import { describe, expect, it } from "vitest";
import { testing } from "./responses-tool-shared.js";
describe("xai responses tool helpers", () => {
it("builds the shared xAI Responses tool body", () => {
expect(
testing.buildXaiResponsesToolBody({
model: "grok-4-1-fast",
inputText: "search for openclaw",
tools: [{ type: "x_search" }],
maxTurns: 2,
}),
).toEqual({
model: "grok-4-1-fast",
input: [{ role: "user", content: "search for openclaw" }],
tools: [{ type: "x_search" }],
max_turns: 2,
});
});
it("falls back to annotation citations when the API omits top-level citations", () => {
expect(
testing.resolveXaiResponseTextAndCitations({
output: [
{
type: "message",
content: [
{
type: "output_text",
text: "Found it",
annotations: [{ type: "url_citation", url: "https://example.com/a" }],
},
],
},
],
}),
).toEqual({
content: "Found it",
citations: ["https://example.com/a"],
});
});
it("ignores malformed output, content, and annotation entries", () => {
expect(
testing.extractXaiWebSearchContent({
output: [
null,
{
type: "message",
content: [
null,
{
type: "output_text",
text: "Found it",
annotations: [
null,
{ type: "url_citation", url: "https://example.com/a" },
{ type: "url_citation", url: "https://example.com/a" },
{ type: "url_citation" },
],
},
],
},
],
}),
).toEqual({
text: "Found it",
annotationCitations: ["https://example.com/a"],
});
});
it("prefers explicit top-level citations when present", () => {
expect(
testing.resolveXaiResponseTextAndCitations({
output_text: "Done",
citations: ["https://example.com/b"],
}),
).toEqual({
content: "Done",
citations: ["https://example.com/b"],
});
});
it("includes inline citations only when enabled", () => {
const data = {
output_text: "Done",
citations: ["https://example.com/b"],
inline_citations: [{ start_index: 0, end_index: 4, url: "https://example.com/b" }],
};
expect(testing.resolveXaiResponseTextCitationsAndInline(data, true)).toEqual({
content: "Done",
citations: ["https://example.com/b"],
inlineCitations: [{ start_index: 0, end_index: 4, url: "https://example.com/b" }],
});
expect(testing.resolveXaiResponseTextCitationsAndInline(data, false)).toEqual({
content: "Done",
citations: ["https://example.com/b"],
inlineCitations: undefined,
});
});
it("rejects successful Responses tool payloads without answer text", () => {
expect(() => testing.requireXaiResponseTextAndCitations({}, "xAI tool failed")).toThrow(
"xAI tool failed: malformed JSON response",
);
});
});

View File

@@ -0,0 +1,164 @@
// Xai plugin module implements responses tool shared behavior.
import {
normalizeOptionalString as trimString,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type { XaiWebSearchResponse } from "./web-search-response.types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
}
function extractUrlCitations(annotations: unknown): string[] {
if (!Array.isArray(annotations)) {
return [];
}
return annotations
.filter(
(annotation) =>
isRecord(annotation) &&
annotation.type === "url_citation" &&
typeof annotation.url === "string",
)
.map((annotation) => annotation.url as string);
}
const XAI_RESPONSES_BASE_URL = "https://api.x.ai/v1";
export const XAI_RESPONSES_ENDPOINT = `${XAI_RESPONSES_BASE_URL}/responses`;
export function resolveXaiResponsesEndpoint(baseUrl?: unknown): string {
return `${(trimString(baseUrl) ?? XAI_RESPONSES_BASE_URL).replace(/\/+$/, "")}/responses`;
}
export function buildXaiResponsesToolBody(params: {
model: string;
inputText: string;
tools: Array<Record<string, unknown>>;
maxTurns?: number;
}): Record<string, unknown> {
return {
model: params.model,
input: [{ role: "user", content: params.inputText }],
tools: params.tools,
...(params.maxTurns ? { max_turns: params.maxTurns } : {}),
};
}
export function extractXaiWebSearchContent(data: XaiWebSearchResponse): {
text: string | undefined;
annotationCitations: string[];
} {
for (const output of data.output ?? []) {
if (!isRecord(output)) {
continue;
}
if (output.type === "message") {
const content = Array.isArray(output.content) ? output.content : [];
for (const block of content) {
if (!isRecord(block)) {
continue;
}
if (block.type === "output_text" && typeof block.text === "string" && block.text) {
const urls = extractUrlCitations(block.annotations);
return { text: block.text, annotationCitations: uniqueStrings(urls) };
}
}
}
if (output.type === "output_text" && typeof output.text === "string" && output.text) {
const urls = extractUrlCitations(output.annotations);
return { text: output.text, annotationCitations: uniqueStrings(urls) };
}
}
return {
text: typeof data.output_text === "string" ? data.output_text : undefined,
annotationCitations: [],
};
}
export function resolveXaiResponseTextAndCitations(data: XaiWebSearchResponse): {
content: string;
citations: string[];
} {
const { text, annotationCitations } = extractXaiWebSearchContent(data);
return {
content: text ?? "No response",
citations:
Array.isArray(data.citations) && data.citations.length > 0
? data.citations
: annotationCitations,
};
}
export function requireXaiResponseTextAndCitations(
data: XaiWebSearchResponse,
label: string,
): {
content: string;
citations: string[];
} {
const { text, annotationCitations } = extractXaiWebSearchContent(data);
if (!text) {
throw new Error(`${label}: malformed JSON response`);
}
return {
content: text,
citations:
Array.isArray(data.citations) && data.citations.length > 0
? data.citations
: annotationCitations,
};
}
export function resolveXaiResponseTextCitationsAndInline(
data: XaiWebSearchResponse,
inlineCitationsEnabled: boolean,
): {
content: string;
citations: string[];
inlineCitations?: XaiWebSearchResponse["inline_citations"];
} {
const { content, citations } = resolveXaiResponseTextAndCitations(data);
return {
content,
citations,
inlineCitations:
inlineCitationsEnabled && Array.isArray(data.inline_citations)
? data.inline_citations
: undefined,
};
}
export function requireXaiResponseTextCitationsAndInline(
data: XaiWebSearchResponse,
label: string,
inlineCitationsEnabled: boolean,
): {
content: string;
citations: string[];
inlineCitations?: XaiWebSearchResponse["inline_citations"];
} {
const { content, citations } = requireXaiResponseTextAndCitations(data, label);
return {
content,
citations,
inlineCitations:
inlineCitationsEnabled && Array.isArray(data.inline_citations)
? data.inline_citations
: undefined,
};
}
export const testing = {
buildXaiResponsesToolBody,
extractXaiWebSearchContent,
requireXaiResponseTextCitationsAndInline,
requireXaiResponseTextAndCitations,
resolveXaiResponseTextCitationsAndInline,
resolveXaiResponseTextAndCitations,
resolveXaiResponsesEndpoint,
XAI_RESPONSES_BASE_URL,
XAI_RESPONSES_ENDPOINT,
} as const;
export { testing as __testing };

View File

@@ -0,0 +1,328 @@
// Xai tests cover tool auth shared plugin behavior.
import { NON_ENV_SECRETREF_MARKER } from "openclaw/plugin-sdk/provider-auth-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
isXaiToolEnabled,
resolveFallbackXaiAuth,
resolveXaiToolApiKeyWithAuth,
} from "./tool-auth-shared.js";
describe("xai tool auth helpers", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("prefers plugin web search keys over legacy grok keys", () => {
expect(
resolveFallbackXaiAuth({
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "plugin-key", // pragma: allowlist secret
},
},
},
},
},
tools: {
web: {
search: {
grok: {
apiKey: "legacy-key", // pragma: allowlist secret
},
},
},
},
}),
).toEqual({
apiKey: "plugin-key",
source: "plugins.entries.xai.config.webSearch.apiKey",
});
});
it("returns source metadata and managed markers for fallback auth", () => {
expect(
resolveFallbackXaiAuth({
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: { source: "file", provider: "vault", id: "/xai/tool-key" },
},
},
},
},
},
}),
).toEqual({
apiKey: NON_ENV_SECRETREF_MARKER,
source: "plugins.entries.xai.config.webSearch.apiKey",
});
expect(
resolveFallbackXaiAuth({
tools: {
web: {
search: {
grok: {
apiKey: "legacy-key", // pragma: allowlist secret
},
},
},
},
}),
).toEqual({
apiKey: "legacy-key",
source: "tools.web.search.grok.apiKey",
});
});
it("falls back to runtime, then source config, then env for tool auth", async () => {
vi.stubEnv("XAI_API_KEY", "env-key");
await expect(
resolveXaiToolApiKeyWithAuth({
runtimeConfig: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "runtime-key", // pragma: allowlist secret
},
},
},
},
},
},
sourceConfig: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "source-key", // pragma: allowlist secret
},
},
},
},
},
},
}),
).resolves.toBe("runtime-key");
await expect(
resolveXaiToolApiKeyWithAuth({
sourceConfig: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "source-key", // pragma: allowlist secret
},
},
},
},
},
},
}),
).resolves.toBe("source-key");
await expect(resolveXaiToolApiKeyWithAuth({})).resolves.toBe("env-key");
});
it("honors explicit disabled flags before auth fallback", () => {
vi.stubEnv("XAI_API_KEY", "env-key");
expect(isXaiToolEnabled({ enabled: false })).toBe(false);
expect(isXaiToolEnabled({ enabled: true })).toBe(true);
});
it("uses xAI auth profiles when tool config and env are absent", async () => {
const auth = {
hasAuthForProvider: (providerId: string) => providerId === "xai",
resolveApiKeyForProvider: async (providerId: string) =>
providerId === "xai" ? "profile-key" : undefined, // pragma: allowlist secret
};
expect(isXaiToolEnabled({ auth })).toBe(true);
await expect(resolveXaiToolApiKeyWithAuth({ auth })).resolves.toBe("profile-key");
});
it("does not use env fallback when a non-env SecretRef is configured but unavailable", async () => {
vi.stubEnv("XAI_API_KEY", "env-key");
await expect(
resolveXaiToolApiKeyWithAuth({
sourceConfig: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: {
source: "file",
provider: "vault",
id: "/xai/tool-key",
},
},
},
},
},
},
},
}),
).resolves.toBeUndefined();
});
it("does not bypass blocked explicit tool config with auth profiles", async () => {
const auth = {
hasAuthForProvider: (providerId: string) => providerId === "xai",
resolveApiKeyForProvider: async () => "profile-key", // pragma: allowlist secret
};
const sourceConfig = {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: {
source: "file",
provider: "vault",
id: "/xai/tool-key",
},
},
},
},
},
},
};
expect(isXaiToolEnabled({ sourceConfig, auth })).toBe(false);
await expect(resolveXaiToolApiKeyWithAuth({ sourceConfig, auth })).resolves.toBeUndefined();
});
it("resolves env SecretRefs from source config when runtime snapshot is unavailable", async () => {
vi.stubEnv("XAI_API_KEY", "xai-secretref-key");
await expect(
resolveXaiToolApiKeyWithAuth({
sourceConfig: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: {
source: "env",
provider: "default",
id: "XAI_API_KEY",
},
},
},
},
},
},
},
}),
).resolves.toBe("xai-secretref-key");
});
it("does not read arbitrary env SecretRef ids for xAI tool auth", async () => {
vi.stubEnv("UNRELATED_SECRET", "should-not-be-read");
await expect(
resolveXaiToolApiKeyWithAuth({
sourceConfig: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: {
source: "env",
provider: "default",
id: "UNRELATED_SECRET",
},
},
},
},
},
},
},
}),
).resolves.toBeUndefined();
});
it("does not resolve env SecretRefs when provider allowlist excludes XAI_API_KEY", async () => {
vi.stubEnv("XAI_API_KEY", "xai-secretref-key");
await expect(
resolveXaiToolApiKeyWithAuth({
sourceConfig: {
secrets: {
providers: {
"xai-env": {
source: "env",
allowlist: ["OTHER_XAI_API_KEY"],
},
},
},
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: {
source: "env",
provider: "xai-env",
id: "XAI_API_KEY",
},
},
},
},
},
},
},
}),
).resolves.toBeUndefined();
});
it("does not resolve env SecretRefs when provider source is not env", async () => {
vi.stubEnv("XAI_API_KEY", "xai-secretref-key");
await expect(
resolveXaiToolApiKeyWithAuth({
sourceConfig: {
secrets: {
providers: {
"xai-env": {
source: "file",
path: "/tmp/secrets.json",
},
},
},
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: {
source: "env",
provider: "xai-env",
id: "XAI_API_KEY",
},
},
},
},
},
},
},
}),
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,194 @@
// Xai plugin module implements tool auth shared behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { canResolveEnvSecretRefInReadOnlyPath } from "openclaw/plugin-sdk/extension-shared";
import {
coerceSecretRef,
resolveNonEnvSecretRefApiKeyMarker,
} from "openclaw/plugin-sdk/provider-auth";
import {
readProviderEnvValue,
resolveProviderWebSearchPluginConfig,
} from "openclaw/plugin-sdk/provider-web-search";
import {
normalizeSecretInputString,
resolveSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
type XaiFallbackAuth = {
apiKey: string;
source: string;
};
const XAI_API_KEY_ENV_VAR = "XAI_API_KEY";
const XAI_PROVIDER_ID = "xai";
export type XaiToolAuthContext = {
hasAuthForProvider?: (providerId: string) => boolean;
resolveApiKeyForProvider?: (providerId: string) => Promise<string | undefined>;
};
type ConfiguredRuntimeApiKeyResolution =
| { status: "available"; value: string }
| { status: "missing" }
| { status: "blocked" };
function readConfiguredOrManagedApiKey(value: unknown): string | undefined {
const literal = normalizeSecretInputString(value);
if (literal) {
return literal;
}
const ref = coerceSecretRef(value);
return ref ? resolveNonEnvSecretRefApiKeyMarker(ref.source) : undefined;
}
function readLegacyGrokFallbackAuth(cfg?: OpenClawConfig): XaiFallbackAuth | undefined {
const search = cfg?.tools?.web?.search;
if (!search || typeof search !== "object") {
return undefined;
}
const grok = (search as Record<string, unknown>).grok;
const apiKey = readConfiguredOrManagedApiKey(
grok && typeof grok === "object" ? (grok as Record<string, unknown>).apiKey : undefined,
);
return apiKey ? { apiKey, source: "tools.web.search.grok.apiKey" } : undefined;
}
function readConfiguredRuntimeApiKey(
value: unknown,
path: string,
cfg?: OpenClawConfig,
): ConfiguredRuntimeApiKeyResolution {
const resolved = resolveSecretInputString({
value,
path,
defaults: cfg?.secrets?.defaults,
mode: "inspect",
});
if (resolved.status === "available") {
return { status: "available", value: resolved.value };
}
if (resolved.status === "missing") {
return { status: "missing" };
}
if (resolved.ref.source !== "env") {
return { status: "blocked" };
}
const envVarName = resolved.ref.id.trim();
if (envVarName !== XAI_API_KEY_ENV_VAR) {
return { status: "blocked" };
}
if (
!canResolveEnvSecretRefInReadOnlyPath({
cfg,
provider: resolved.ref.provider,
id: envVarName,
})
) {
return { status: "blocked" };
}
const envValue = normalizeSecretInputString(process.env[envVarName]);
return envValue ? { status: "available", value: envValue } : { status: "missing" };
}
function readLegacyGrokApiKeyResult(cfg?: OpenClawConfig): ConfiguredRuntimeApiKeyResolution {
const search = cfg?.tools?.web?.search;
if (!search || typeof search !== "object") {
return { status: "missing" };
}
const grok = (search as Record<string, unknown>).grok;
return readConfiguredRuntimeApiKey(
grok && typeof grok === "object" ? (grok as Record<string, unknown>).apiKey : undefined,
"tools.web.search.grok.apiKey",
cfg,
);
}
function readPluginXaiWebSearchApiKeyResult(
cfg?: OpenClawConfig,
): ConfiguredRuntimeApiKeyResolution {
return readConfiguredRuntimeApiKey(
resolveProviderWebSearchPluginConfig(cfg as Record<string, unknown> | undefined, "xai")?.apiKey,
"plugins.entries.xai.config.webSearch.apiKey",
cfg,
);
}
function resolveConfiguredXaiToolApiKeyResult(params: {
runtimeConfig?: OpenClawConfig;
sourceConfig?: OpenClawConfig;
}): ConfiguredRuntimeApiKeyResolution {
const runtimePlugin = readPluginXaiWebSearchApiKeyResult(params.runtimeConfig);
if (runtimePlugin.status === "available" || runtimePlugin.status === "blocked") {
return runtimePlugin;
}
const runtimeLegacy = readLegacyGrokApiKeyResult(params.runtimeConfig);
if (runtimeLegacy.status === "available" || runtimeLegacy.status === "blocked") {
return runtimeLegacy;
}
const sourcePlugin = readPluginXaiWebSearchApiKeyResult(params.sourceConfig);
if (sourcePlugin.status === "available" || sourcePlugin.status === "blocked") {
return sourcePlugin;
}
const sourceLegacy = readLegacyGrokApiKeyResult(params.sourceConfig);
if (sourceLegacy.status === "available" || sourceLegacy.status === "blocked") {
return sourceLegacy;
}
return { status: "missing" };
}
function hasXaiAuthProfile(auth?: XaiToolAuthContext): boolean {
return auth?.hasAuthForProvider?.(XAI_PROVIDER_ID) === true;
}
async function resolveXaiAuthProfileApiKey(auth?: XaiToolAuthContext): Promise<string | undefined> {
const value = await auth?.resolveApiKeyForProvider?.(XAI_PROVIDER_ID);
return normalizeSecretInputString(value);
}
export function resolveFallbackXaiAuth(cfg?: OpenClawConfig): XaiFallbackAuth | undefined {
const pluginApiKey = readConfiguredOrManagedApiKey(
resolveProviderWebSearchPluginConfig(cfg as Record<string, unknown> | undefined, "xai")?.apiKey,
);
if (pluginApiKey) {
return {
apiKey: pluginApiKey,
source: "plugins.entries.xai.config.webSearch.apiKey",
};
}
return readLegacyGrokFallbackAuth(cfg);
}
export async function resolveXaiToolApiKeyWithAuth(params: {
runtimeConfig?: OpenClawConfig;
sourceConfig?: OpenClawConfig;
auth?: XaiToolAuthContext;
}): Promise<string | undefined> {
const configured = resolveConfiguredXaiToolApiKeyResult(params);
if (configured.status === "available") {
return configured.value;
}
if (configured.status === "blocked") {
return undefined;
}
return (
(await resolveXaiAuthProfileApiKey(params.auth)) ?? readProviderEnvValue([XAI_API_KEY_ENV_VAR])
);
}
export function isXaiToolEnabled(params: {
enabled?: boolean;
runtimeConfig?: OpenClawConfig;
sourceConfig?: OpenClawConfig;
auth?: XaiToolAuthContext;
}): boolean {
if (params.enabled === false) {
return false;
}
const configured = resolveConfiguredXaiToolApiKeyResult(params);
if (configured.status === "available") {
return true;
}
if (configured.status === "blocked") {
return false;
}
return hasXaiAuthProfile(params.auth) || Boolean(readProviderEnvValue([XAI_API_KEY_ENV_VAR]));
}

View File

@@ -0,0 +1,37 @@
// Xai tests cover tool config shared plugin behavior.
import { describe, expect, it } from "vitest";
import {
coerceXaiToolConfig,
resolveNormalizedXaiToolModel,
resolvePositiveIntegerToolConfig,
} from "./tool-config-shared.js";
describe("xai tool config helpers", () => {
it("coerces non-record config to an empty object", () => {
expect(coerceXaiToolConfig(undefined)).toStrictEqual({});
expect(coerceXaiToolConfig([] as unknown as Record<string, unknown>)).toStrictEqual({});
});
it("normalizes configured model ids and falls back to the default model", () => {
expect(
resolveNormalizedXaiToolModel({
config: { model: " grok-4.1-fast " },
defaultModel: "grok-4-1-fast",
}),
).toBe("grok-4.1-fast");
expect(
resolveNormalizedXaiToolModel({
config: {},
defaultModel: "grok-4-1-fast",
}),
).toBe("grok-4-1-fast");
});
it("accepts only positive finite numeric turn counts", () => {
expect(resolvePositiveIntegerToolConfig({ maxTurns: 2.9 }, "maxTurns")).toBe(2);
expect(resolvePositiveIntegerToolConfig({ maxTurns: 0 }, "maxTurns")).toBeUndefined();
expect(resolvePositiveIntegerToolConfig({ maxTurns: Number.NaN }, "maxTurns")).toBeUndefined();
expect(resolvePositiveIntegerToolConfig(undefined, "maxTurns")).toBeUndefined();
});
});

View File

@@ -0,0 +1,33 @@
// Xai helper module supports tool config shared behavior.
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeXaiModelId } from "../model-id.js";
export { isRecord };
export function coerceXaiToolConfig(
config: Record<string, unknown> | undefined,
): Record<string, unknown> {
return isRecord(config) ? config : {};
}
export function resolveNormalizedXaiToolModel(params: {
config?: Record<string, unknown>;
defaultModel: string;
}): string {
const value = coerceXaiToolConfig(params.config).model;
return typeof value === "string" && value.trim()
? normalizeXaiModelId(value.trim())
: params.defaultModel;
}
export function resolvePositiveIntegerToolConfig(
config: Record<string, unknown> | undefined,
key: string,
): number | undefined {
const raw = coerceXaiToolConfig(config)[key];
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return undefined;
}
const normalized = Math.trunc(raw);
return normalized > 0 ? normalized : undefined;
}

View File

@@ -0,0 +1,433 @@
// Xai provider module implements model/runtime integration.
import { resolveDefaultAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
coerceSecretRef,
ensureAuthProfileStore,
listUsableProviderAuthProfileIds,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
DEFAULT_CACHE_TTL_MINUTES,
formatCliCommand,
getScopedCredentialValue,
mergeScopedSearchConfig,
normalizeCacheKey,
readCache,
readPositiveIntegerParam,
readStringParam,
resolveCacheTtlMs,
resolveProviderWebSearchPluginConfig,
resolveTimeoutSeconds,
resolveWebSearchProviderCredential,
type WebSearchProviderSetupContext,
writeCache,
} from "openclaw/plugin-sdk/provider-web-search";
import {
buildXaiWebSearchPayload,
extractXaiWebSearchContent,
requestXaiWebSearch,
resolveXaiInlineCitations,
resolveXaiWebSearchEndpoint,
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 XAI_WEB_SEARCH_DEFAULT_TIMEOUT_SECONDS = 60;
const XAI_PROVIDER_ID = "xai";
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 credential you 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 credential",
},
{
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;
endpoint: string;
apiKey: string;
timeoutSeconds: number;
inlineCitations: boolean;
cacheTtlMs: number;
}): Promise<Record<string, unknown>> {
const cacheKey = normalizeCacheKey(
`grok:${params.endpoint}:${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,
endpoint: params.endpoint,
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"],
});
}
function resolveConfiguredXaiWebSearchCredential(
searchConfig?: Record<string, unknown>,
): string | undefined {
return resolveWebSearchProviderCredential({
credentialValue: getScopedCredentialValue(searchConfig, "grok"),
path: "tools.web.search.grok.apiKey",
envVars: [],
});
}
function hasConfiguredXaiWebSearchCredentialRef(searchConfig?: Record<string, unknown>): boolean {
return coerceSecretRef(getScopedCredentialValue(searchConfig, "grok")) !== null;
}
type XaiResolvedWebSearchAuth = {
apiKey: string;
mode?: "api-key" | "oauth" | "token" | "aws-sdk";
profileId?: string;
};
async function resolveXaiProviderAuthCredential(params: {
config?: Record<string, unknown>;
agentDir?: string;
credentialPrecedence?: "profile-first" | "env-first";
forceRefresh?: boolean;
profileId?: string;
}): Promise<XaiResolvedWebSearchAuth | undefined> {
try {
const config = params.config as OpenClawConfig | undefined;
const agentDir =
params.agentDir?.trim() || (config ? resolveDefaultAgentDir(config) : undefined);
const resolved = await resolveApiKeyForProvider({
provider: XAI_PROVIDER_ID,
cfg: config,
...(agentDir ? { agentDir } : {}),
...(params.profileId
? {
profileId: params.profileId,
lockedProfile: true,
}
: {}),
...(params.forceRefresh ? { forceRefresh: true } : {}),
...(params.credentialPrecedence ? { credentialPrecedence: params.credentialPrecedence } : {}),
});
const apiKey = typeof resolved.apiKey === "string" ? resolved.apiKey.trim() : "";
if (!apiKey) {
return undefined;
}
return {
apiKey,
mode: resolved.mode,
...(resolved.profileId ? { profileId: resolved.profileId } : {}),
};
} catch {
return undefined;
}
}
async function resolveXaiProviderApiKeyProfileFallback(params: {
config?: Record<string, unknown>;
agentDir?: string;
}): Promise<XaiResolvedWebSearchAuth | undefined> {
const config = params.config as OpenClawConfig | undefined;
const usableProfiles = listUsableProviderAuthProfileIds({
agentDir: params.agentDir,
cfg: config,
provider: XAI_PROVIDER_ID,
});
if (!usableProfiles.agentDir || usableProfiles.profileIds.length === 0) {
return undefined;
}
const store = ensureAuthProfileStore(usableProfiles.agentDir, {
allowKeychainPrompt: false,
});
for (const profileId of usableProfiles.profileIds) {
const profile = store.profiles[profileId];
if (!profile || profile.provider !== XAI_PROVIDER_ID || profile.type === "oauth") {
continue;
}
const resolved = await resolveXaiProviderAuthCredential({
agentDir: usableProfiles.agentDir,
config: params.config,
profileId,
});
if (resolved?.apiKey && resolved.mode !== "oauth") {
return resolved;
}
}
return undefined;
}
async function resolveXaiWebSearchAuth(
ctx: { config?: Record<string, unknown>; agentDir?: string },
searchConfig?: Record<string, unknown>,
options?: { forceRefresh?: boolean; profileId?: string },
): Promise<XaiResolvedWebSearchAuth | undefined> {
const providerAuth = await resolveXaiProviderAuthCredential({
agentDir: ctx.agentDir,
config: ctx.config,
forceRefresh: options?.forceRefresh,
profileId: options?.profileId,
});
if (providerAuth?.mode === "oauth") {
return providerAuth;
}
const configured = resolveConfiguredXaiWebSearchCredential(searchConfig);
if (configured) {
return {
apiKey: configured,
mode: "api-key",
};
}
if (hasConfiguredXaiWebSearchCredentialRef(searchConfig)) {
return undefined;
}
return providerAuth;
}
async function resolveXaiWebSearchApiKeyFallback(
ctx: { config?: Record<string, unknown>; agentDir?: string },
searchConfig?: Record<string, unknown>,
): Promise<XaiResolvedWebSearchAuth | undefined> {
const configured = resolveConfiguredXaiWebSearchCredential(searchConfig);
if (configured) {
return {
apiKey: configured,
mode: "api-key",
};
}
if (hasConfiguredXaiWebSearchCredentialRef(searchConfig)) {
return undefined;
}
const providerAuth = await resolveXaiProviderAuthCredential({
agentDir: ctx.agentDir,
config: ctx.config,
credentialPrecedence: "env-first",
});
if (providerAuth?.apiKey && providerAuth.mode !== "oauth") {
return providerAuth;
}
return await resolveXaiProviderApiKeyProfileFallback({
agentDir: ctx.agentDir,
config: ctx.config,
});
}
function isXaiUnauthorizedError(error: unknown): boolean {
return error instanceof Error && error.message.includes("xAI API error (401)");
}
function resolveXaiWebSearchTimeoutSeconds(searchConfig?: Record<string, unknown>): number {
return resolveTimeoutSeconds(
searchConfig?.timeoutSeconds,
XAI_WEB_SEARCH_DEFAULT_TIMEOUT_SECONDS,
);
}
export async function executeXaiWebSearchProviderTool(
ctx: {
config?: Record<string, unknown>;
searchConfig?: Record<string, unknown>;
agentDir?: string;
},
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const searchConfig = resolveXaiToolSearchConfig(ctx);
const auth = await resolveXaiWebSearchAuth(ctx, searchConfig);
if (!auth) {
return {
error: "missing_xai_api_key",
message:
"web_search (grok) needs xAI credentials. Run `openclaw onboard --auth-choice xai-oauth` to sign in with Grok, run `openclaw onboard --auth-choice xai-api-key`, set `XAI_API_KEY` in the Gateway environment, or configure `plugins.entries.xai.config.webSearch.apiKey`. If you do not want to configure search credentials, use web_fetch for a specific URL or the browser tool for interactive pages.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const query = readStringParam(args, "query", { required: true });
void readPositiveIntegerParam(args, "count", {
max: 10,
message: "count must be an integer from 1 to 10.",
});
const request = {
query,
model: resolveXaiWebSearchModel(searchConfig),
endpoint: resolveXaiWebSearchEndpoint(searchConfig),
timeoutSeconds: resolveXaiWebSearchTimeoutSeconds(searchConfig),
inlineCitations: resolveXaiInlineCitations(searchConfig),
cacheTtlMs: resolveCacheTtlMs(searchConfig?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
};
try {
return await runXaiWebSearch({
...request,
apiKey: auth.apiKey,
});
} catch (error) {
if (!isXaiUnauthorizedError(error) || !auth.profileId) {
throw error;
}
if (auth.mode === "oauth") {
const refreshed = await resolveXaiWebSearchAuth(ctx, searchConfig, {
forceRefresh: true,
profileId: auth.profileId,
});
if (refreshed?.apiKey && refreshed.apiKey !== auth.apiKey) {
return await runXaiWebSearch({
...request,
apiKey: refreshed.apiKey,
});
}
}
const fallback = await resolveXaiWebSearchApiKeyFallback(ctx, searchConfig);
if (!fallback?.apiKey || fallback.apiKey === auth.apiKey) {
throw error;
}
return await runXaiWebSearch({
...request,
apiKey: fallback.apiKey,
});
}
}
export const testing = {
buildXaiWebSearchPayload,
extractXaiWebSearchContent,
resolveXaiToolSearchConfig,
resolveXaiWebSearchAuth,
resolveXaiInlineCitations,
resolveXaiWebSearchCredential,
resolveXaiWebSearchEndpoint,
resolveXaiWebSearchModel,
resolveXaiWebSearchTimeoutSeconds,
requestXaiWebSearch,
};
export { testing as __testing };

View File

@@ -0,0 +1,26 @@
// Xai type declarations define plugin contracts.
export type XaiWebSearchResponse = {
output?: Array<{
type?: string;
text?: string;
content?: Array<{
type?: string;
text?: string;
annotations?: Array<{
type?: string;
url?: string;
} | null>;
} | null>;
annotations?: Array<{
type?: string;
url?: string;
} | null>;
} | null>;
output_text?: string;
citations?: string[];
inline_citations?: Array<{
start_index: number;
end_index: number;
url: string;
}>;
};

View File

@@ -0,0 +1,125 @@
// Xai plugin module implements web search shared behavior.
import { readProviderJsonObjectResponse } from "openclaw/plugin-sdk/provider-http";
import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search";
import { normalizeXaiModelId } from "../model-id.js";
import {
buildXaiResponsesToolBody,
requireXaiResponseTextCitationsAndInline,
resolveXaiResponsesEndpoint,
} from "./responses-tool-shared.js";
import { isRecord } from "./tool-config-shared.js";
import type { XaiWebSearchResponse } from "./web-search-response.types.js";
export { extractXaiWebSearchContent } from "./responses-tool-shared.js";
export type { XaiWebSearchResponse } from "./web-search-response.types.js";
const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast";
type XaiWebSearchConfig = Record<string, unknown> & {
baseUrl?: unknown;
model?: unknown;
inlineCitations?: unknown;
};
type XaiWebSearchResult = {
content: string;
citations: string[];
inlineCitations?: XaiWebSearchResponse["inline_citations"];
};
export function buildXaiWebSearchPayload(params: {
query: string;
provider: string;
model: string;
tookMs: number;
content: string;
citations: string[];
inlineCitations?: XaiWebSearchResponse["inline_citations"];
}): Record<string, unknown> {
return {
query: params.query,
provider: params.provider,
model: params.model,
tookMs: params.tookMs,
externalContent: {
untrusted: true,
source: "web_search",
provider: params.provider,
wrapped: true,
},
content: wrapWebContent(params.content, "web_search"),
citations: params.citations,
...(params.inlineCitations ? { inlineCitations: params.inlineCitations } : {}),
};
}
function resolveXaiSearchConfig(searchConfig?: Record<string, unknown>): XaiWebSearchConfig {
return (
(isRecord(searchConfig?.grok) ? (searchConfig.grok as XaiWebSearchConfig) : undefined) ?? {}
);
}
export function resolveXaiWebSearchModel(searchConfig?: Record<string, unknown>): string {
const config = resolveXaiSearchConfig(searchConfig);
return typeof config.model === "string" && config.model.trim()
? normalizeXaiModelId(config.model.trim())
: XAI_DEFAULT_WEB_SEARCH_MODEL;
}
export function resolveXaiWebSearchEndpoint(searchConfig?: Record<string, unknown>): string {
return resolveXaiResponsesEndpoint(resolveXaiSearchConfig(searchConfig).baseUrl);
}
export function resolveXaiInlineCitations(searchConfig?: Record<string, unknown>): boolean {
return resolveXaiSearchConfig(searchConfig).inlineCitations === true;
}
function isAbortError(error: unknown): boolean {
return (
error instanceof Error &&
(error.name === "AbortError" || error.message === "This operation was aborted")
);
}
export function wrapXaiWebSearchError(error: unknown, timeoutSeconds: number): never {
if (isAbortError(error)) {
throw new Error(
`xAI web search timed out after ${timeoutSeconds}s. Increase tools.web.search.timeoutSeconds if queries are complex.`,
{ cause: error },
);
}
throw error;
}
export async function requestXaiWebSearch(params: {
query: string;
model: string;
apiKey: string;
endpoint: string;
timeoutSeconds: number;
inlineCitations: boolean;
}): Promise<XaiWebSearchResult> {
return await postTrustedWebToolsJson(
{
url: params.endpoint,
timeoutSeconds: params.timeoutSeconds,
apiKey: params.apiKey,
body: buildXaiResponsesToolBody({
model: params.model,
inputText: params.query,
tools: [{ type: "web_search" }],
}),
errorLabel: "xAI",
},
async (response) => {
const data = (await readProviderJsonObjectResponse(
response,
"xAI web search failed",
)) as XaiWebSearchResponse;
return requireXaiResponseTextCitationsAndInline(
data,
"xAI web search failed",
params.inlineCitations,
);
},
).catch((error: unknown) => wrapXaiWebSearchError(error, params.timeoutSeconds));
}

View File

@@ -0,0 +1,79 @@
// Xai helper module supports x search config behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isRecord } from "./tool-config-shared.js";
type JsonRecord = Record<string, unknown>;
function cloneRecord<T extends JsonRecord | undefined>(value: T): T {
if (!value) {
return value;
}
return { ...value } as T;
}
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;
}
function resolvePluginXSearchConfig(config?: OpenClawConfig): JsonRecord | undefined {
const pluginConfig = config?.plugins?.entries?.xai?.config;
if (!isRecord(pluginConfig?.xSearch)) {
return undefined;
}
return cloneRecord(pluginConfig.xSearch);
}
function resolveLegacyGrokWebSearchConfig(config?: OpenClawConfig): JsonRecord | undefined {
const web = config?.tools?.web as Record<string, unknown> | undefined;
const search = web?.search;
if (!isRecord(search) || !isRecord(search.grok)) {
return undefined;
}
return cloneRecord(search.grok);
}
function resolvePluginWebSearchConfig(config?: OpenClawConfig): JsonRecord | undefined {
const pluginConfig = config?.plugins?.entries?.xai?.config;
if (!isRecord(pluginConfig?.webSearch)) {
return undefined;
}
return cloneRecord(pluginConfig.webSearch);
}
function baseUrlFallback(config?: JsonRecord): JsonRecord | undefined {
return typeof config?.baseUrl === "string" && config.baseUrl.trim()
? { baseUrl: config.baseUrl }
: undefined;
}
export function resolveEffectiveXSearchConfig(config?: OpenClawConfig): JsonRecord | undefined {
const legacyGrokBaseUrl = baseUrlFallback(resolveLegacyGrokWebSearchConfig(config));
const pluginWebSearchBaseUrl = baseUrlFallback(resolvePluginWebSearchConfig(config));
const legacy = resolveLegacyXSearchConfig(config);
const pluginOwned = resolvePluginXSearchConfig(config);
const merged = {
...legacyGrokBaseUrl,
...pluginWebSearchBaseUrl,
...legacy,
...pluginOwned,
};
if (Object.keys(merged).length === 0) {
return undefined;
}
return merged;
}
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

@@ -0,0 +1,147 @@
// Xai plugin module implements x search shared behavior.
import { readProviderJsonObjectResponse } from "openclaw/plugin-sdk/provider-http";
import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search";
import {
buildXaiResponsesToolBody,
requireXaiResponseTextCitationsAndInline,
resolveXaiResponsesEndpoint,
} from "./responses-tool-shared.js";
import {
coerceXaiToolConfig,
resolveNormalizedXaiToolModel,
resolvePositiveIntegerToolConfig,
} from "./tool-config-shared.js";
import type { XaiWebSearchResponse } from "./web-search-shared.js";
export const XAI_DEFAULT_X_SEARCH_MODEL = "grok-4-1-fast-non-reasoning";
type XaiXSearchConfig = {
apiKey?: unknown;
baseUrl?: unknown;
model?: unknown;
inlineCitations?: unknown;
maxTurns?: unknown;
};
export type XaiXSearchOptions = {
query: string;
allowedXHandles?: string[];
excludedXHandles?: string[];
fromDate?: string;
toDate?: string;
enableImageUnderstanding?: boolean;
enableVideoUnderstanding?: boolean;
};
type XaiXSearchResult = {
content: string;
citations: string[];
inlineCitations?: XaiWebSearchResponse["inline_citations"];
};
function resolveXaiXSearchConfig(config?: Record<string, unknown>): XaiXSearchConfig {
return coerceXaiToolConfig(config) as XaiXSearchConfig;
}
export function resolveXaiXSearchModel(config?: Record<string, unknown>): string {
return resolveNormalizedXaiToolModel({
config,
defaultModel: XAI_DEFAULT_X_SEARCH_MODEL,
});
}
export function resolveXaiXSearchEndpoint(config?: Record<string, unknown>): string {
return resolveXaiResponsesEndpoint(resolveXaiXSearchConfig(config).baseUrl);
}
export function resolveXaiXSearchInlineCitations(config?: Record<string, unknown>): boolean {
return resolveXaiXSearchConfig(config).inlineCitations === true;
}
export function resolveXaiXSearchMaxTurns(config?: Record<string, unknown>): number | undefined {
return resolvePositiveIntegerToolConfig(config, "maxTurns");
}
function buildXSearchTool(options: XaiXSearchOptions): Record<string, unknown> {
return {
type: "x_search",
...(options.allowedXHandles?.length ? { allowed_x_handles: options.allowedXHandles } : {}),
...(options.excludedXHandles?.length ? { excluded_x_handles: options.excludedXHandles } : {}),
...(options.fromDate ? { from_date: options.fromDate } : {}),
...(options.toDate ? { to_date: options.toDate } : {}),
...(options.enableImageUnderstanding ? { enable_image_understanding: true } : {}),
...(options.enableVideoUnderstanding ? { enable_video_understanding: true } : {}),
};
}
export function buildXaiXSearchPayload(params: {
query: string;
model: string;
tookMs: number;
content: string;
citations: string[];
inlineCitations?: XaiWebSearchResponse["inline_citations"];
options?: XaiXSearchOptions;
}): Record<string, unknown> {
return {
query: params.query,
provider: "xai",
model: params.model,
tookMs: params.tookMs,
externalContent: {
untrusted: true,
source: "x_search",
provider: "xai",
wrapped: true,
},
content: wrapWebContent(params.content, "web_search"),
citations: params.citations,
...(params.inlineCitations ? { inlineCitations: params.inlineCitations } : {}),
...(params.options?.allowedXHandles?.length
? { allowedXHandles: params.options.allowedXHandles }
: {}),
...(params.options?.excludedXHandles?.length
? { excludedXHandles: params.options.excludedXHandles }
: {}),
...(params.options?.fromDate ? { fromDate: params.options.fromDate } : {}),
...(params.options?.toDate ? { toDate: params.options.toDate } : {}),
...(params.options?.enableImageUnderstanding ? { enableImageUnderstanding: true } : {}),
...(params.options?.enableVideoUnderstanding ? { enableVideoUnderstanding: true } : {}),
};
}
export async function requestXaiXSearch(params: {
apiKey: string;
endpoint: string;
model: string;
timeoutSeconds: number;
inlineCitations: boolean;
maxTurns?: number;
options: XaiXSearchOptions;
}): Promise<XaiXSearchResult> {
return await postTrustedWebToolsJson(
{
url: params.endpoint,
timeoutSeconds: params.timeoutSeconds,
apiKey: params.apiKey,
body: buildXaiResponsesToolBody({
model: params.model,
inputText: params.options.query,
tools: [buildXSearchTool(params.options)],
maxTurns: params.maxTurns,
}),
errorLabel: "xAI",
},
async (response) => {
const data = (await readProviderJsonObjectResponse(
response,
"xAI X search failed",
)) as XaiWebSearchResponse;
return requireXaiResponseTextCitationsAndInline(
data,
"xAI X search failed",
params.inlineCitations,
);
},
);
}

View File

@@ -0,0 +1,60 @@
// Xai tests cover xai user agent plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { xaiUserAgent, xaiUserAgentHeaderFor } from "./xai-user-agent.js";
describe("xaiUserAgent", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("prefers OPENCLAW_VERSION env over the bundled package version", () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
expect(xaiUserAgent()).toBe("openclaw/2026.3.22");
});
it("falls back to OPENCLAW_SERVICE_VERSION when OPENCLAW_VERSION is unset", () => {
vi.stubEnv("OPENCLAW_VERSION", "");
vi.stubEnv("OPENCLAW_SERVICE_VERSION", "2026.3.99");
// OPENCLAW_VERSION from the SDK is the bundled VERSION constant. In a dev
// checkout it resolves to a real semver, so we cannot deterministically
// assert "unknown" here. We just lock the prefix to ensure the env-first
// contract holds whenever the bundle resolves to 0.0.0/empty.
const result = xaiUserAgent();
expect(result.startsWith("openclaw/")).toBe(true);
expect(result).not.toBe("openclaw/");
});
it("returns the openclaw/<version> shape", () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.5.16");
expect(xaiUserAgent()).toMatch(/^openclaw\/\d+\.\d+\.\d+$/u);
});
});
describe("xaiUserAgentHeaderFor", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("emits User-Agent for the xAI-native host", () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
expect(xaiUserAgentHeaderFor("https://api.x.ai/v1")).toEqual({
"User-Agent": "openclaw/2026.3.22",
});
expect(xaiUserAgentHeaderFor("https://api.x.ai/v1/tts")).toEqual({
"User-Agent": "openclaw/2026.3.22",
});
});
it("withholds User-Agent on user-configured proxy baseUrls", () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
expect(xaiUserAgentHeaderFor("https://my-corp.proxy/xai/v1")).toEqual({});
expect(xaiUserAgentHeaderFor("http://127.0.0.1:8080/v1")).toEqual({});
expect(xaiUserAgentHeaderFor("https://api.grok.x.ai/v1")).toEqual({});
});
it("returns an empty record for missing or invalid input", () => {
expect(xaiUserAgentHeaderFor(undefined)).toEqual({});
expect(xaiUserAgentHeaderFor("")).toEqual({});
expect(xaiUserAgentHeaderFor("not a url")).toEqual({});
});
});

View File

@@ -0,0 +1,52 @@
// Shared User-Agent for xAI sidecar HTTP/WS requests; mirrors `formatOpenClawUserAgent`.
import { OPENCLAW_VERSION as PACKAGE_VERSION } from "openclaw/plugin-sdk/agent-harness-runtime";
const ORIGINATOR = "openclaw";
const UNUSABLE_PACKAGE_VERSION = "0.0.0";
const FALLBACK_VERSION = "unknown";
function trimToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed && trimmed.length > 0 ? trimmed : undefined;
}
function resolveXaiUserAgentVersion(): string {
// Env-first matches resolveRuntimeServiceVersion.
const envVersion = trimToUndefined(process.env.OPENCLAW_VERSION);
if (envVersion) {
return envVersion;
}
const packageVersion = trimToUndefined(PACKAGE_VERSION);
if (packageVersion && packageVersion !== UNUSABLE_PACKAGE_VERSION) {
return packageVersion;
}
return (
trimToUndefined(process.env.OPENCLAW_SERVICE_VERSION) ??
trimToUndefined(process.env.npm_package_version) ??
FALLBACK_VERSION
);
}
export function xaiUserAgent(): string {
return `${ORIGINATOR}/${resolveXaiUserAgentVersion()}`;
}
const XAI_NATIVE_API_HOSTS = new Set(["api.x.ai"]);
// Returns a `User-Agent` header entry only when the resolved baseUrl points
// at a verified xAI-native API host. User-configured proxy baseUrls produce
// an empty record so the openclaw identity is not forwarded to the proxy.
export function xaiUserAgentHeaderFor(baseUrl: string | undefined): Record<string, string> {
if (!baseUrl) {
return {};
}
try {
if (XAI_NATIVE_API_HOSTS.has(new URL(baseUrl).hostname)) {
return { "User-Agent": xaiUserAgent() };
}
} catch {
return {};
}
return {};
}

View File

@@ -0,0 +1,655 @@
// Xai tests cover stream plugin behavior.
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { streamSimple, type Api, type Context, type Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it } from "vitest";
import { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js";
import {
createXaiFastModeWrapper,
createXaiToolPayloadCompatibilityWrapper,
wrapXaiProviderStream,
} from "./stream.js";
import {
createXaiPayloadCaptureStream,
expectXaiFastToolStreamShaping,
runXaiGrok4ResponseStream,
} from "./test-helpers.js";
type XaiStreamApi = Extract<Api, "openai-completions" | "openai-responses">;
type StreamEvent = Record<string, unknown> & { type?: string };
async function collectEvents(stream: ReturnType<StreamFn>): Promise<StreamEvent[]> {
const events: StreamEvent[] = [];
for await (const event of stream as AsyncIterable<StreamEvent>) {
events.push(event);
}
return events;
}
function buildEventStreamFn(events: unknown[]): StreamFn {
return (() =>
({
result: async () => {
const done = events.find((event) => {
const record = event && typeof event === "object" ? (event as { type?: unknown }) : {};
return record.type === "done";
}) as { message?: unknown } | undefined;
return (done?.message ?? { role: "assistant", content: [] }) as never;
},
async *[Symbol.asyncIterator]() {
for (const event of events) {
yield event as never;
}
},
}) as unknown as ReturnType<StreamFn>) as StreamFn;
}
function captureWrappedModelId(params: {
modelId: string;
fastMode: boolean | (() => boolean | undefined);
api?: XaiStreamApi;
}): string {
let capturedModelId = "";
const baseStreamFn: StreamFn = (model) => {
capturedModelId = model.id;
return {} as ReturnType<StreamFn>;
};
const wrapped = createXaiFastModeWrapper(baseStreamFn, params.fastMode);
void wrapped(
{
api: params.api ?? "openai-responses",
provider: "xai",
id: params.modelId,
} as Model<Extract<Api, "openai-completions" | "openai-responses">>,
{ messages: [] } as Context,
{},
);
return capturedModelId;
}
function runXaiToolPayloadWrapper(params: {
payload: Record<string, unknown>;
api?: XaiStreamApi;
modelId?: string;
input?: string[];
}) {
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(params.payload, {} as Model<XaiStreamApi>);
return {} as ReturnType<StreamFn>;
};
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
const api = params.api ?? "openai-responses";
void wrapped(
{
api,
provider: "xai",
id:
params.modelId ??
(api === "openai-completions" ? "grok-4-1-fast-reasoning" : "grok-4-fast"),
reasoning: params.modelId ? !params.modelId.includes("non-reasoning") : true,
...(params.input ? { input: params.input } : {}),
} as Model<XaiStreamApi>,
{ messages: [] } as Context,
{},
);
}
async function captureXaiResponsesPayloadWithThinking(): Promise<Record<string, unknown>> {
const model = applyXaiRuntimeModelCompat({
api: "openai-responses",
provider: "xai",
id: "grok-4.3",
baseUrl: "https://api.x.ai/v1",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 64_000,
} as Model<"openai-responses">);
const payloadPromise = new Promise<Record<string, unknown>>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("provider payload callback was not invoked")),
1_000,
);
const stream = streamSimple(
model,
{ messages: [{ role: "user", content: "hello", timestamp: 0 }] },
{
apiKey: "test-api-key",
cacheRetention: "none",
reasoning: "low",
onPayload: (payload) => {
clearTimeout(timeout);
resolve(structuredClone(payload as Record<string, unknown>));
throw new Error("stop after payload capture");
},
},
);
void stream.result();
});
return await payloadPromise;
}
describe("xai stream wrappers", () => {
it("rewrites supported Grok models to fast variants when fast mode is enabled", () => {
expect(captureWrappedModelId({ modelId: "grok-3", fastMode: true })).toBe("grok-3-fast");
expect(
captureWrappedModelId({
modelId: "grok-3",
fastMode: true,
api: "openai-completions",
}),
).toBe("grok-3-fast");
expect(captureWrappedModelId({ modelId: "grok-4", fastMode: true })).toBe("grok-4-fast");
expect(
captureWrappedModelId({
modelId: "grok-3",
fastMode: true,
api: "openai-responses",
}),
).toBe("grok-3-fast");
});
it("leaves unsupported or disabled models unchanged", () => {
expect(captureWrappedModelId({ modelId: "grok-3-fast", fastMode: true })).toBe("grok-3-fast");
expect(captureWrappedModelId({ modelId: "grok-3", fastMode: false })).toBe("grok-3");
});
it("resolves dynamic fast mode for each xai stream call", () => {
const capturedModelIds: string[] = [];
const baseStreamFn: StreamFn = (model) => {
capturedModelIds.push(model.id);
return {
result: async () => ({}),
async *[Symbol.asyncIterator]() {},
} as unknown as ReturnType<StreamFn>;
};
let enabled = true;
const wrapped = createXaiFastModeWrapper(baseStreamFn, () => enabled);
const model = {
api: "openai-responses",
provider: "xai",
id: "grok-4",
} as Model<XaiStreamApi>;
void wrapped(model, { messages: [] } as Context, {});
enabled = false;
void wrapped(model, { messages: [] } as Context, {});
expect(capturedModelIds).toEqual(["grok-4-fast", "grok-4"]);
});
it("composes the xai provider stream chain from extra params", () => {
const capture = createXaiPayloadCaptureStream();
const wrapped = wrapXaiProviderStream({
streamFn: capture.streamFn,
extraParams: { fastMode: true },
} as never);
runXaiGrok4ResponseStream(wrapped);
expectXaiFastToolStreamShaping(capture);
});
it("leaves tool-call argument html entities untouched, delegating decode to the core path", async () => {
const toolCall = {
type: "toolCall",
id: "call_1",
name: "write",
arguments: { content: "&amp;amp;" },
};
const assistant = {
role: "assistant",
content: [toolCall],
stopReason: "toolUse",
};
const baseStream = buildEventStreamFn([
{ type: "toolcall_end", contentIndex: 0, toolCall, partial: assistant },
{ type: "done", reason: "toolUse", message: assistant },
]);
const wrapped = wrapXaiProviderStream({
streamFn: baseStream,
extraParams: { tool_stream: false },
} as never);
const events = await collectEvents(
wrapped!(
{ api: "openai-responses", provider: "xai", id: "grok-4.3" } as Model<"openai-responses">,
{ messages: [], tools: [] } as unknown as Context,
{},
),
);
const done = events.find((event) => event.type === "done") as {
message?: { content?: Array<{ arguments?: { content?: string } }> };
};
expect(done.message?.content?.[0]?.arguments?.content).toBe("&amp;amp;");
});
it("promotes standalone Grok-style tool text to a structured tool call", async () => {
const rawToolText = '[tool:read] {"path":"/app/skills/meme-maker/SKILL.md"}';
const baseStream = buildEventStreamFn([
{ type: "start", partial: { content: [] } },
{ type: "text_start", contentIndex: 0, partial: { content: [{ type: "text", text: "" }] } },
{ type: "text_delta", contentIndex: 0, delta: rawToolText },
{ type: "text_end", contentIndex: 0, content: rawToolText },
{
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: rawToolText }],
stopReason: "stop",
},
},
]);
const wrapped = wrapXaiProviderStream({
streamFn: baseStream,
extraParams: { tool_stream: false },
} as never);
const events = await collectEvents(
wrapped!(
{
api: "openai-responses",
provider: "xai",
id: "grok-4.3",
} as Model<"openai-responses">,
{
messages: [],
tools: [{ name: "read", description: "Read", parameters: { type: "object" } }],
} as unknown as Context,
{},
),
);
expect(events.map((event) => event.type)).toEqual([
"start",
"toolcall_start",
"toolcall_delta",
"done",
]);
const done = events.find((event) => event.type === "done") as {
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
reason?: string;
};
expect(done.reason).toBe("toolUse");
expect(done.message?.stopReason).toBe("toolUse");
expect(done.message?.content?.[0]).toMatchObject({
type: "toolCall",
name: "read",
arguments: { path: "/app/skills/meme-maker/SKILL.md" },
});
});
it("resolves dynamic fast mode in the composed xai provider stream chain", () => {
const capturedModelIds: string[] = [];
const baseStreamFn: StreamFn = (model) => {
capturedModelIds.push(model.id);
return {
result: async () => ({}),
async *[Symbol.asyncIterator]() {},
} as unknown as ReturnType<StreamFn>;
};
let enabled = true;
const wrapped = wrapXaiProviderStream({
streamFn: baseStreamFn,
extraParams: { fastMode: () => enabled },
} as never);
const model = {
api: "openai-responses",
provider: "xai",
id: "grok-4",
} as Model<XaiStreamApi>;
void wrapped?.(model, { messages: [] } as Context, {});
enabled = false;
void wrapped?.(model, { messages: [] } as Context, {});
expect(capturedModelIds).toEqual(["grok-4-fast", "grok-4"]);
});
it("strips unsupported strict and reasoning controls from tool payloads", () => {
const payload = {
reasoning: "high",
reasoningEffort: "high",
reasoning_effort: "high",
tools: [
{
type: "function",
function: {
name: "write",
parameters: { type: "object", properties: {} },
strict: true,
},
},
],
};
runXaiToolPayloadWrapper({
payload,
api: "openai-completions",
modelId: "grok-4-fast-non-reasoning",
});
expect(payload).not.toHaveProperty("reasoning");
expect(payload).not.toHaveProperty("reasoningEffort");
expect(payload).not.toHaveProperty("reasoning_effort");
expect(payload.tools[0]?.function).not.toHaveProperty("strict");
});
it("strips unsupported reasoning controls from non-reasoning xai payloads", () => {
const payload: Record<string, unknown> = {
reasoning: { effort: "high" },
reasoningEffort: "high",
reasoning_effort: "high",
};
runXaiToolPayloadWrapper({ payload, modelId: "grok-4-fast-non-reasoning" });
expect(payload).not.toHaveProperty("reasoning");
expect(payload).not.toHaveProperty("reasoningEffort");
expect(payload).not.toHaveProperty("reasoning_effort");
});
it("passes reasoning controls through for reasoning-capable xai payloads", () => {
const payload: Record<string, unknown> = {
reasoning: { effort: "high" },
reasoningEffort: "high",
reasoning_effort: "high",
};
runXaiToolPayloadWrapper({ payload, modelId: "grok-4.3" });
expect(payload.reasoning).toEqual({ effort: "high" });
expect(payload.reasoningEffort).toBe("high");
expect(payload.reasoning_effort).toBe("high");
});
it("strips reasoning controls when compat disables reasoning effort", () => {
const payload: Record<string, unknown> = {
reasoning: { effort: "high" },
reasoningEffort: "high",
reasoning_effort: "high",
};
const baseStreamFn: StreamFn = (model, _context, options) => {
options?.onPayload?.(payload, model);
return {} as ReturnType<StreamFn>;
};
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
void wrapped(
{
api: "openai-responses",
provider: "xai",
id: "grok-4.20-beta-latest-reasoning",
reasoning: true,
compat: { supportsReasoningEffort: false },
} as unknown as Model<"openai-responses">,
{ messages: [] } as Context,
{},
);
expect(payload).not.toHaveProperty("reasoning");
expect(payload).not.toHaveProperty("reasoningEffort");
expect(payload).not.toHaveProperty("reasoning_effort");
});
it("still requests encrypted reasoning include when effort is unsupported", () => {
const payload: Record<string, unknown> = {
reasoning: { effort: "high" },
input: [],
};
const baseStreamFn: StreamFn = (model, _context, options) => {
options?.onPayload?.(payload, model);
return {} as ReturnType<StreamFn>;
};
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
void wrapped(
{
api: "openai-responses",
provider: "xai",
id: "grok-build-0.1",
reasoning: true,
compat: { supportsReasoningEffort: false },
} as unknown as Model<"openai-responses">,
{ messages: [] } as Context,
{},
);
expect(payload).not.toHaveProperty("reasoning");
expect(payload.include).toEqual(["reasoning.encrypted_content"]);
});
it("merges encrypted reasoning include with existing include entries", () => {
const payload: Record<string, unknown> = {
include: ["file_search_call.results"],
};
runXaiToolPayloadWrapper({
payload,
modelId: "grok-build-0.1",
});
expect(payload.include).toEqual(["file_search_call.results", "reasoning.encrypted_content"]);
});
it("does not request encrypted reasoning include for non-reasoning xai models", () => {
const payload: Record<string, unknown> = {};
runXaiToolPayloadWrapper({
payload,
modelId: "grok-4-fast-non-reasoning",
});
expect(payload).not.toHaveProperty("include");
});
it("keeps native xAI Responses thinking efforts before the shared runtime dispatches payloads", async () => {
const payload = await captureXaiResponsesPayloadWithThinking();
expect(payload.reasoning).toEqual({ effort: "low", summary: "auto" });
expect(payload.include).toEqual(["reasoning.encrypted_content"]);
});
it("moves image-bearing tool results out of function_call_output payloads", () => {
const payload: Record<string, unknown> = {
input: [
{
type: "function_call_output",
call_id: "call_1",
output: [
{ type: "input_text", text: "Read image" },
{
type: "input_image",
detail: "auto",
image_url: "data:image/png;base64,QUJDRA==",
},
],
},
],
};
runXaiToolPayloadWrapper({ payload, input: ["text", "image"] });
expect(payload.input).toEqual([
{
type: "function_call_output",
call_id: "call_1",
output: "Read image",
},
{
type: "message",
role: "user",
content: [
{ type: "input_text", text: "Attached image(s) from tool result:" },
{
type: "input_image",
detail: "auto",
image_url: "data:image/png;base64,QUJDRA==",
},
],
},
]);
});
it("replays source-based input_image parts from tool results", () => {
const payload: Record<string, unknown> = {
input: [
{
type: "function_call_output",
call_id: "call_1",
output: [
{ type: "input_text", text: "Read image" },
{
type: "input_image",
source: {
type: "base64",
media_type: "image/png",
data: "QUJDRA==",
},
},
],
},
],
};
runXaiToolPayloadWrapper({ payload, input: ["text", "image"] });
expect(payload.input).toEqual([
{
type: "function_call_output",
call_id: "call_1",
output: "Read image",
},
{
type: "message",
role: "user",
content: [
{ type: "input_text", text: "Attached image(s) from tool result:" },
{
type: "input_image",
source: {
type: "base64",
media_type: "image/png",
data: "QUJDRA==",
},
},
],
},
]);
});
it("keeps multiple tool outputs contiguous before replaying collected images", () => {
const payload: Record<string, unknown> = {
input: [
{
type: "function_call_output",
call_id: "call_1",
output: [
{ type: "input_text", text: "first" },
{
type: "input_image",
detail: "auto",
image_url: "data:image/png;base64,QUFBQQ==",
},
],
},
{
type: "function_call_output",
call_id: "call_2",
output: [
{ type: "input_text", text: "second" },
{
type: "input_image",
detail: "auto",
image_url: "data:image/png;base64,QkJCQg==",
},
],
},
],
};
runXaiToolPayloadWrapper({ payload, input: ["text", "image"] });
expect(payload.input).toEqual([
{
type: "function_call_output",
call_id: "call_1",
output: "first",
},
{
type: "function_call_output",
call_id: "call_2",
output: "second",
},
{
type: "message",
role: "user",
content: [
{ type: "input_text", text: "Attached image(s) from tool result:" },
{
type: "input_image",
detail: "auto",
image_url: "data:image/png;base64,QUFBQQ==",
},
{
type: "input_image",
detail: "auto",
image_url: "data:image/png;base64,QkJCQg==",
},
],
},
]);
});
it("drops image blocks and uses fallback text for models without image input", () => {
const payload: Record<string, unknown> = {
input: [
{
type: "function_call_output",
call_id: "call_1",
output: [
{
type: "input_image",
detail: "auto",
image_url: "data:image/png;base64,QUJDRA==",
},
],
},
],
};
runXaiToolPayloadWrapper({ payload, input: ["text"] });
expect(payload.input).toEqual([
{
type: "function_call_output",
call_id: "call_1",
output: "(see attached image)",
},
]);
});
it("uses audio fallback text for audio-only tool outputs", () => {
const payload: Record<string, unknown> = {
input: [
{
type: "function_call_output",
call_id: "call_audio",
output: [
{
type: "input_audio",
mimeType: "audio/wav",
data: "QUJDRA==",
},
],
},
],
};
runXaiToolPayloadWrapper({ payload, input: ["text"] });
expect(payload.input).toEqual([
{
type: "function_call_output",
call_id: "call_audio",
output: "(see attached audio)",
},
]);
});
});

314
extensions/xai/stream.ts Normal file
View File

@@ -0,0 +1,314 @@
// Xai plugin module implements stream behavior.
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { streamSimple } from "openclaw/plugin-sdk/llm";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import {
composeProviderStreamWrappers,
createPlainTextToolCallCompatWrapper,
createToolStreamWrapper,
} from "openclaw/plugin-sdk/provider-stream-shared";
const XAI_FAST_MODEL_IDS = new Map<string, string>([
["grok-3", "grok-3-fast"],
["grok-3-mini", "grok-3-mini-fast"],
["grok-4", "grok-4-fast"],
["grok-4-0709", "grok-4-fast"],
]);
type DynamicFastMode = boolean | (() => boolean | undefined);
function resolveXaiFastModelId(modelId: unknown): string | undefined {
if (typeof modelId !== "string") {
return undefined;
}
return XAI_FAST_MODEL_IDS.get(modelId.trim());
}
function stripUnsupportedStrictFlag(tool: unknown): unknown {
if (!tool || typeof tool !== "object") {
return tool;
}
const toolObj = tool as Record<string, unknown>;
const fn = toolObj.function;
if (!fn || typeof fn !== "object") {
return tool;
}
const fnObj = fn as Record<string, unknown>;
if (typeof fnObj.strict !== "boolean") {
return tool;
}
const nextFunction = { ...fnObj };
delete nextFunction.strict;
return { ...toolObj, function: nextFunction };
}
function supportsExplicitImageInput(model: { input?: unknown }): boolean {
return Array.isArray(model.input) && model.input.includes("image");
}
function supportsReasoningControls(model: { compat?: unknown; reasoning?: unknown }): boolean {
const compat =
model.compat && typeof model.compat === "object"
? (model.compat as { supportsReasoningEffort?: unknown })
: undefined;
return model.reasoning === true && compat?.supportsReasoningEffort !== false;
}
const XAI_REASONING_ENCRYPTED_CONTENT_INCLUDE = "reasoning.encrypted_content";
/** xAI-only: request encrypted reasoning for every reasoning-capable model, even when effort is unsupported. */
function ensureXaiResponsesEncryptedReasoningInclude(
payloadObj: Record<string, unknown>,
model: { api?: unknown; provider?: unknown; reasoning?: unknown },
): void {
if (model.provider !== "xai" || model.api !== "openai-responses" || model.reasoning !== true) {
return;
}
const existing = payloadObj.include;
const include = Array.isArray(existing)
? existing.filter((entry): entry is string => typeof entry === "string")
: [];
if (!include.includes(XAI_REASONING_ENCRYPTED_CONTENT_INCLUDE)) {
include.push(XAI_REASONING_ENCRYPTED_CONTENT_INCLUDE);
}
payloadObj.include = include;
}
const TOOL_RESULT_IMAGE_REPLAY_TEXT = "Attached image(s) from tool result:";
type ReplayableInputImagePart =
| {
type: "input_image";
source: { type: "url"; url: string } | { type: "base64"; media_type: string; data: string };
}
| { type: "input_image"; image_url: string; detail?: string };
type NormalizedFunctionCallOutput = {
normalizedItem: unknown;
imageParts: Array<Record<string, unknown>>;
};
function isReplayableInputImagePart(
part: Record<string, unknown>,
): part is ReplayableInputImagePart {
if (part.type !== "input_image") {
return false;
}
if (typeof part.image_url === "string") {
return true;
}
if (!part.source || typeof part.source !== "object") {
return false;
}
const source = part.source as {
type?: unknown;
url?: unknown;
media_type?: unknown;
data?: unknown;
};
if (source.type === "url") {
return typeof source.url === "string";
}
return (
source.type === "base64" &&
typeof source.media_type === "string" &&
typeof source.data === "string"
);
}
function describeXaiFunctionOutputMediaPlaceholder(
parts: Array<Record<string, unknown>>,
): string | undefined {
let hasImage = false;
let hasAudio = false;
let hasOtherMedia = false;
for (const part of parts) {
const type = typeof part.type === "string" ? part.type : "";
const mimeType =
typeof part.mimeType === "string"
? part.mimeType
: typeof part.mime_type === "string"
? part.mime_type
: typeof part.mediaType === "string"
? part.mediaType
: typeof part.contentType === "string"
? part.contentType
: "";
const normalizedMime = mimeType.toLowerCase();
if (type.includes("image") || normalizedMime.startsWith("image/")) {
hasImage = true;
} else if (type.includes("audio") || normalizedMime.startsWith("audio/")) {
hasAudio = true;
} else if (type !== "input_text") {
hasOtherMedia = true;
}
}
if ((hasImage && hasAudio) || hasOtherMedia) {
return "(see attached media)";
}
if (hasAudio) {
return "(see attached audio)";
}
if (hasImage) {
return "(see attached image)";
}
return undefined;
}
function normalizeXaiResponsesFunctionCallOutput(
item: unknown,
includeImages: boolean,
): NormalizedFunctionCallOutput {
if (!item || typeof item !== "object") {
return { normalizedItem: item, imageParts: [] };
}
const itemObj = item as Record<string, unknown>;
if (itemObj.type !== "function_call_output" || !Array.isArray(itemObj.output)) {
return { normalizedItem: itemObj, imageParts: [] };
}
const outputParts = itemObj.output as Array<Record<string, unknown>>;
const textOutput = outputParts
.filter(
(part): part is { type: "input_text"; text: string } =>
part.type === "input_text" && typeof part.text === "string",
)
.map((part) => part.text)
.join("");
const imageParts = includeImages
? outputParts.filter((part): part is ReplayableInputImagePart =>
isReplayableInputImagePart(part),
)
: [];
const hadNonTextParts = outputParts.some((part) => part.type !== "input_text");
const mediaPlaceholder = describeXaiFunctionOutputMediaPlaceholder(outputParts);
return {
normalizedItem: {
...itemObj,
output: textOutput || mediaPlaceholder || (hadNonTextParts ? "(see attached media)" : ""),
},
imageParts,
};
}
function normalizeXaiResponsesToolResultPayload(
payloadObj: Record<string, unknown>,
model: { api?: unknown; input?: unknown },
): void {
if (model.api !== "openai-responses" || !Array.isArray(payloadObj.input)) {
return;
}
const includeImages = supportsExplicitImageInput(model);
const normalizedInput: unknown[] = [];
const collectedImageParts: Array<Record<string, unknown>> = [];
for (const item of payloadObj.input) {
const normalized = normalizeXaiResponsesFunctionCallOutput(item, includeImages);
normalizedInput.push(normalized.normalizedItem);
collectedImageParts.push(...normalized.imageParts);
}
if (collectedImageParts.length > 0) {
normalizedInput.push({
type: "message",
role: "user",
content: [
{ type: "input_text", text: TOOL_RESULT_IMAGE_REPLAY_TEXT },
...collectedImageParts,
],
});
}
payloadObj.input = normalizedInput;
}
export function createXaiToolPayloadCompatibilityWrapper(
baseStreamFn: StreamFn | undefined,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
if (Array.isArray(payloadObj.tools)) {
payloadObj.tools = payloadObj.tools.map((tool) => stripUnsupportedStrictFlag(tool));
}
normalizeXaiResponsesToolResultPayload(payloadObj, model);
if (!supportsReasoningControls(model)) {
// Only grok-4.3* advertises configurable effort; drop effort fields elsewhere.
delete payloadObj.reasoning;
delete payloadObj.reasoningEffort;
delete payloadObj.reasoning_effort;
}
// All reasoning xAI models should still request + later replay encrypted_content.
ensureXaiResponsesEncryptedReasoningInclude(payloadObj, model);
}
return originalOnPayload?.(payload, model);
},
});
};
}
export function createXaiFastModeWrapper(
baseStreamFn: StreamFn | undefined,
fastMode: DynamicFastMode,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const supportsFastAliasTransport =
model.api === "openai-completions" || model.api === "openai-responses";
if (
(typeof fastMode === "function" ? fastMode() : fastMode) !== true ||
!supportsFastAliasTransport ||
model.provider !== "xai"
) {
return underlying(model, context, options);
}
const fastModelId = resolveXaiFastModelId(model.id);
if (!fastModelId) {
return underlying(model, context, options);
}
return underlying({ ...model, id: fastModelId }, context, options);
};
}
function resolveXaiFastMode(extraParams: Record<string, unknown> | undefined): boolean | undefined {
const raw = extraParams?.fastMode ?? extraParams?.fast_mode;
if (typeof raw === "function") {
const resolved = (raw as () => unknown)();
return typeof resolved === "boolean" ? resolved : undefined;
}
return typeof raw === "boolean" ? raw : undefined;
}
function hasXaiFastModeParam(extraParams: Record<string, unknown> | undefined): boolean {
return Boolean(
extraParams &&
(Object.hasOwn(extraParams, "fastMode") || Object.hasOwn(extraParams, "fast_mode")),
);
}
export function wrapXaiProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn | undefined {
const extraParams = ctx.extraParams;
const toolStreamEnabled = extraParams?.tool_stream !== false;
return composeProviderStreamWrappers(ctx.streamFn, (streamFn) => {
let wrappedStreamFn = createXaiToolPayloadCompatibilityWrapper(streamFn);
if (hasXaiFastModeParam(extraParams)) {
wrappedStreamFn = createXaiFastModeWrapper(wrappedStreamFn, () =>
resolveXaiFastMode(extraParams),
);
}
wrappedStreamFn = createPlainTextToolCallCompatWrapper(wrappedStreamFn);
return createToolStreamWrapper(wrappedStreamFn, toolStreamEnabled);
});
}

107
extensions/xai/stt.test.ts Normal file
View File

@@ -0,0 +1,107 @@
// Xai tests cover stt plugin behavior.
import { describe, expect, it, vi } from "vitest";
import {
buildXaiMediaUnderstandingProvider,
transcribeXaiAudio,
XAI_DEFAULT_STT_MODEL,
} from "./stt.js";
const { postTranscriptionRequestMock } = vi.hoisted(() => ({
postTranscriptionRequestMock: vi.fn(
async (_params: { headers: Headers; body: BodyInit; url: string; timeoutMs?: number }) => ({
response: new Response(JSON.stringify({ text: "hello from audio" }), { status: 200 }),
release: vi.fn(),
}),
),
}));
function requireLastPostTranscriptionCall(): {
url?: string;
timeoutMs?: number;
auditContext?: string;
headers: Headers;
body: BodyInit;
} {
const params = (postTranscriptionRequestMock.mock.calls as unknown as Array<[unknown]>).at(
-1,
)?.[0] as
| {
url?: string;
timeoutMs?: number;
auditContext?: string;
headers?: Headers;
body?: BodyInit;
}
| undefined;
if (!params?.headers || !params.body) {
throw new Error("Expected transcription request params");
}
return {
...params,
headers: params.headers,
body: params.body,
};
}
vi.mock("openclaw/plugin-sdk/provider-http", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-http")>();
return {
...actual,
postTranscriptionRequest: postTranscriptionRequestMock,
};
});
describe("xai stt", () => {
it("posts audio files to the xAI STT endpoint", async () => {
const result = await transcribeXaiAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "sample.wav",
mime: "audio/wav",
apiKey: "xai-key",
baseUrl: "https://api.x.ai/v1/",
model: XAI_DEFAULT_STT_MODEL,
language: "en",
prompt: "ignored provider hint",
timeoutMs: 10_000,
});
expect(result).toEqual({ text: "hello from audio", model: XAI_DEFAULT_STT_MODEL });
const call = requireLastPostTranscriptionCall();
expect(call.url).toBe("https://api.x.ai/v1/stt");
expect(call.timeoutMs).toBe(10_000);
expect(call.auditContext).toBe("xai stt");
expect(call.headers.get("authorization")).toBe("Bearer xai-key");
expect(call.body).toBeInstanceOf(FormData);
const form = call.body as FormData;
expect(form.get("model")).toBe(XAI_DEFAULT_STT_MODEL);
expect(form.get("language")).toBe("en");
expect(form.get("prompt")).toBeNull();
expect(form.get("file")).toBeInstanceOf(Blob);
});
it("registers as an audio media-understanding provider", () => {
const provider = buildXaiMediaUnderstandingProvider();
expect(provider.id).toBe("xai");
expect(provider.capabilities).toEqual(["audio"]);
expect(provider.defaultModels).toEqual({ audio: XAI_DEFAULT_STT_MODEL });
expect(provider.autoPriority).toEqual({ audio: 25 });
});
it("trusts the core-resolved apiKey on transcribeAudio (no plugin-side OAuth fallback)", async () => {
const provider = buildXaiMediaUnderstandingProvider();
if (!provider.transcribeAudio) {
throw new Error("xAI media-understanding provider should register transcribeAudio");
}
await provider.transcribeAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "sample.wav",
mime: "audio/wav",
apiKey: "core-resolved-bearer",
baseUrl: "https://api.x.ai/v1/",
model: XAI_DEFAULT_STT_MODEL,
timeoutMs: 10_000,
});
const call = requireLastPostTranscriptionCall();
expect(call.headers.get("authorization")).toBe("Bearer core-resolved-bearer");
});
});

93
extensions/xai/stt.ts Normal file
View File

@@ -0,0 +1,93 @@
// Xai plugin module implements stt behavior.
import type {
AudioTranscriptionRequest,
AudioTranscriptionResult,
MediaUnderstandingProvider,
} from "openclaw/plugin-sdk/media-understanding";
import {
assertOkOrThrowHttpError,
buildAudioTranscriptionFormData,
postTranscriptionRequest,
readProviderJsonResponse,
requireTranscriptionText,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { XAI_BASE_URL } from "./model-definitions.js";
export const XAI_DEFAULT_STT_MODEL = "grok-stt";
type XaiSttResponse = {
text?: string;
};
function resolveXaiSttBaseUrl(value?: string): string {
return normalizeOptionalString(value ?? process.env.XAI_BASE_URL) ?? XAI_BASE_URL;
}
export async function transcribeXaiAudio(
params: AudioTranscriptionRequest,
): Promise<AudioTranscriptionResult> {
const fetchFn = params.fetchFn ?? fetch;
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveXaiSttBaseUrl(params.baseUrl),
defaultBaseUrl: XAI_BASE_URL,
headers: params.headers,
request: params.request,
defaultHeaders: {
Authorization: `Bearer ${params.apiKey}`,
},
provider: "xai",
api: "xai-stt",
capability: "audio",
transport: "media-understanding",
});
const model = normalizeOptionalString(params.model);
const language = normalizeOptionalString(params.language);
const form = buildAudioTranscriptionFormData({
buffer: params.buffer,
fileName: params.fileName,
mime: params.mime,
fields: {
model,
language,
},
});
const { response, release } = await postTranscriptionRequest({
url: `${baseUrl}/stt`,
headers,
body: form,
timeoutMs: params.timeoutMs,
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
auditContext: "xai stt",
});
try {
await assertOkOrThrowHttpError(response, "xAI audio transcription failed");
const payload = await readProviderJsonResponse<XaiSttResponse>(response, "xai.stt");
return {
text: requireTranscriptionText(payload.text, "xAI transcription response missing text"),
...(model ? { model } : {}),
};
} finally {
await release();
}
}
export function buildXaiMediaUnderstandingProvider(): MediaUnderstandingProvider {
// Auth is resolved by media-understanding core via resolveProviderExecutionContext
// before transcribeAudio runs, so an OAuth profile (when configured) reaches
// here as `params.apiKey` already. No plugin-side fallback required.
return {
id: "xai",
capabilities: ["audio"],
defaultModels: { audio: XAI_DEFAULT_STT_MODEL },
autoPriority: { audio: 25 },
transcribeAudio: transcribeXaiAudio,
};
}

View File

@@ -0,0 +1,2 @@
// Xai API module exposes the plugin public contract.
export { testing, testing as __testing } from "./src/web-search-provider.runtime.js";

View File

@@ -0,0 +1,74 @@
// Xai helper module supports test helpers behavior.
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { Context, Model } from "openclaw/plugin-sdk/llm";
import { expect } from "vitest";
type XaiToolPayloadFunction = {
function?: Record<string, unknown>;
};
type XaiTestPayload = Record<string, unknown> & {
tools?: Array<{ type?: string; function?: Record<string, unknown> }>;
input?: unknown[];
};
function createXaiToolStreamPayload(): XaiTestPayload {
return {
reasoning: { effort: "high" },
tools: [
{
type: "function",
function: {
name: "write",
parameters: { type: "object", properties: {} },
strict: true,
},
},
],
};
}
export function createXaiPayloadCaptureStream() {
let capturedModelId = "";
let capturedPayload: XaiTestPayload | undefined;
const streamFn: StreamFn = (model, _context, options) => {
capturedModelId = model.id;
const payload = createXaiToolStreamPayload();
options?.onPayload?.(payload as never, model as never);
capturedPayload = payload;
return {
result: async () => ({}) as never,
async *[Symbol.asyncIterator]() {},
} as unknown as ReturnType<StreamFn>;
};
return {
streamFn,
getCapturedModelId: () => capturedModelId,
getCapturedPayload: () => capturedPayload,
};
}
export function runXaiGrok4ResponseStream(streamFn: StreamFn | null | undefined) {
void streamFn?.(
{
api: "openai-responses",
provider: "xai",
id: "grok-4",
} as Model<"openai-responses">,
{ messages: [] } as Context,
{},
);
}
export function expectXaiFastToolStreamShaping(
capture: ReturnType<typeof createXaiPayloadCaptureStream>,
) {
const capturedPayload = capture.getCapturedPayload();
expect(capture.getCapturedModelId()).toBe("grok-4-fast");
expect(capturedPayload).toMatchObject({ tool_stream: true });
expect(capturedPayload).not.toHaveProperty("reasoning");
const payloadTools = capturedPayload?.tools as XaiToolPayloadFunction[] | undefined;
expect(payloadTools?.[0]?.function).not.toHaveProperty("strict");
}

View File

@@ -0,0 +1,418 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": ".",
"paths": {
"openclaw/extension-api": [
"../../src/extensionAPI.ts"
],
"openclaw/plugin-sdk": [
"../../dist/plugin-sdk/index.d.ts"
],
"openclaw/plugin-sdk/*": [
"../../dist/plugin-sdk/*.d.ts"
],
"openclaw/plugin-sdk/reply-payload-testing": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/reply-payload-testing.d.ts"
],
"openclaw/plugin-sdk/sqlite-runtime-testing": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/sqlite-runtime-testing.d.ts"
],
"openclaw/plugin-sdk/ssrf-runtime-internal": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/ssrf-runtime-internal.d.ts"
],
"openclaw/plugin-sdk/codex-native-task-runtime": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/codex-native-task-runtime.d.ts"
],
"openclaw/plugin-sdk/agent-runtime-test-contracts": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/agent-runtime-test-contracts.d.ts"
],
"openclaw/plugin-sdk/channel-target-testing": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/channel-target-testing.d.ts"
],
"openclaw/plugin-sdk/channel-test-helpers": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/channel-test-helpers.d.ts"
],
"openclaw/plugin-sdk/plugin-test-api": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/plugin-test-api.d.ts"
],
"openclaw/plugin-sdk/plugin-test-contracts": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/plugin-test-contracts.d.ts"
],
"openclaw/plugin-sdk/plugin-test-runtime": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/plugin-test-runtime.d.ts"
],
"openclaw/plugin-sdk/provider-http-test-mocks": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/provider-http-test-mocks.d.ts"
],
"openclaw/plugin-sdk/provider-test-contracts": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/provider-test-contracts.d.ts"
],
"openclaw/plugin-sdk/test-env": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/test-env.d.ts"
],
"openclaw/plugin-sdk/test-fixtures": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/test-fixtures.d.ts"
],
"openclaw/plugin-sdk/test-node-mocks": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/test-node-mocks.d.ts"
],
"openclaw/plugin-sdk/testing": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/testing.d.ts"
],
"openclaw/plugin-sdk/plugin-state-test-runtime": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/plugin-state-test-runtime.d.ts"
],
"openclaw/plugin-sdk/channel-contract-testing": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/channel-contract-testing.d.ts"
],
"openclaw/plugin-sdk/account-id": [
"../../dist/plugin-sdk/account-id.d.ts"
],
"openclaw/plugin-sdk/channel-entry-contract": [
"../../dist/plugin-sdk/channel-entry-contract.d.ts"
],
"openclaw/plugin-sdk/browser-maintenance": [
"../../dist/plugin-sdk/src/plugin-sdk/browser-maintenance.d.ts"
],
"openclaw/plugin-sdk/channel-secret-runtime": [
"../../dist/plugin-sdk/channel-secret-runtime.d.ts"
],
"openclaw/plugin-sdk/channel-streaming": [
"../../dist/plugin-sdk/channel-streaming.d.ts"
],
"openclaw/plugin-sdk/error-runtime": [
"../../dist/plugin-sdk/error-runtime.d.ts"
],
"openclaw/plugin-sdk/provider-catalog-live-runtime": [
"../../dist/plugin-sdk/provider-catalog-live-runtime.d.ts"
],
"openclaw/plugin-sdk/provider-catalog-shared": [
"../../dist/plugin-sdk/provider-catalog-shared.d.ts"
],
"openclaw/plugin-sdk/provider-entry": [
"../../dist/plugin-sdk/provider-entry.d.ts"
],
"openclaw/plugin-sdk/secret-ref-runtime": [
"../../dist/plugin-sdk/secret-ref-runtime.d.ts"
],
"openclaw/plugin-sdk/ssrf-runtime": [
"../../dist/plugin-sdk/ssrf-runtime.d.ts"
],
"@openclaw/qa-channel/api.js": [
"../../dist/plugin-sdk/extensions/qa-channel/api.d.ts"
],
"@openclaw/ai": [
"../../dist/plugin-sdk/packages/ai/src/index.d.ts"
],
"@openclaw/ai/diagnostics": [
"../../dist/plugin-sdk/packages/ai/src/utils/diagnostics.d.ts"
],
"@openclaw/ai/event-stream": [
"../../dist/plugin-sdk/packages/ai/src/utils/event-stream.d.ts"
],
"@openclaw/ai/providers": [
"../../dist/plugin-sdk/packages/ai/src/providers.d.ts"
],
"@openclaw/ai/types": [
"../../dist/plugin-sdk/packages/ai/src/types.d.ts"
],
"@openclaw/ai/validation": [
"../../dist/plugin-sdk/packages/ai/src/validation.d.ts"
],
"@openclaw/ai/internal/*": [
"../../dist/plugin-sdk/packages/ai/src/internal/*.d.ts"
],
"@openclaw/llm-core": [
"../../dist/plugin-sdk/packages/llm-core/src/index.d.ts"
],
"@openclaw/llm-core/diagnostics": [
"../../dist/plugin-sdk/packages/llm-core/src/utils/diagnostics.d.ts"
],
"@openclaw/llm-core/event-stream": [
"../../dist/plugin-sdk/packages/llm-core/src/utils/event-stream.d.ts"
],
"@openclaw/llm-core/types": [
"../../dist/plugin-sdk/packages/llm-core/src/types.d.ts"
],
"@openclaw/llm-core/validation": [
"../../dist/plugin-sdk/packages/llm-core/src/validation.d.ts"
],
"@openclaw/llm-core/*": [
"../../dist/plugin-sdk/packages/llm-core/src/*.d.ts"
],
"@openclaw/model-catalog-core": [
"../../dist/plugin-sdk/packages/model-catalog-core/src/index.d.ts"
],
"@openclaw/model-catalog-core/configured-model-refs": [
"../../dist/plugin-sdk/packages/model-catalog-core/src/configured-model-refs.d.ts"
],
"@openclaw/model-catalog-core/model-catalog-refs": [
"../../dist/plugin-sdk/packages/model-catalog-core/src/model-catalog-refs.d.ts"
],
"@openclaw/model-catalog-core/model-catalog-normalize": [
"../../dist/plugin-sdk/packages/model-catalog-core/src/model-catalog-normalize.d.ts"
],
"@openclaw/model-catalog-core/model-catalog-types": [
"../../dist/plugin-sdk/packages/model-catalog-core/src/model-catalog-types.d.ts"
],
"@openclaw/model-catalog-core/provider-id": [
"../../dist/plugin-sdk/packages/model-catalog-core/src/provider-id.d.ts"
],
"@openclaw/model-catalog-core/provider-model-id-normalization": [
"../../dist/plugin-sdk/packages/model-catalog-core/src/provider-model-id-normalization.d.ts"
],
"@openclaw/model-catalog-core/provider-model-id-normalize": [
"../../dist/plugin-sdk/packages/model-catalog-core/src/provider-model-id-normalize.d.ts"
],
"@openclaw/model-catalog-core/*": [
"../../dist/plugin-sdk/packages/model-catalog-core/src/*.d.ts"
],
"@openclaw/markdown-core": [
"../../dist/plugin-sdk/packages/markdown-core/src/index.d.ts"
],
"@openclaw/markdown-core/code-spans": [
"../../dist/plugin-sdk/packages/markdown-core/src/code-spans.d.ts"
],
"@openclaw/markdown-core/fences": [
"../../dist/plugin-sdk/packages/markdown-core/src/fences.d.ts"
],
"@openclaw/markdown-core/frontmatter": [
"../../dist/plugin-sdk/packages/markdown-core/src/frontmatter.d.ts"
],
"@openclaw/markdown-core/ir": [
"../../dist/plugin-sdk/packages/markdown-core/src/ir.d.ts"
],
"@openclaw/markdown-core/render": [
"../../dist/plugin-sdk/packages/markdown-core/src/render.d.ts"
],
"@openclaw/markdown-core/render-aware-chunking": [
"../../dist/plugin-sdk/packages/markdown-core/src/render-aware-chunking.d.ts"
],
"@openclaw/markdown-core/tables": [
"../../dist/plugin-sdk/packages/markdown-core/src/tables.d.ts"
],
"@openclaw/markdown-core/types": [
"../../dist/plugin-sdk/packages/markdown-core/src/types.d.ts"
],
"@openclaw/markdown-core/*": [
"../../dist/plugin-sdk/packages/markdown-core/src/*.d.ts"
],
"@openclaw/media-generation-core": [
"../../dist/plugin-sdk/packages/media-generation-core/src/index.d.ts"
],
"@openclaw/media-generation-core/capability-model-ref": [
"../../dist/plugin-sdk/packages/media-generation-core/src/capability-model-ref.d.ts"
],
"@openclaw/media-generation-core/catalog": [
"../../dist/plugin-sdk/packages/media-generation-core/src/catalog.d.ts"
],
"@openclaw/media-generation-core/model-ref": [
"../../dist/plugin-sdk/packages/media-generation-core/src/model-ref.d.ts"
],
"@openclaw/media-generation-core/normalization": [
"../../dist/plugin-sdk/packages/media-generation-core/src/normalization.d.ts"
],
"@openclaw/media-generation-core/*": [
"../../dist/plugin-sdk/packages/media-generation-core/src/*.d.ts"
],
"@openclaw/media-core": [
"../../dist/plugin-sdk/packages/media-core/src/index.d.ts"
],
"@openclaw/media-core/base64": [
"../../dist/plugin-sdk/packages/media-core/src/base64.d.ts"
],
"@openclaw/media-core/constants": [
"../../dist/plugin-sdk/packages/media-core/src/constants.d.ts"
],
"@openclaw/media-core/content-length": [
"../../dist/plugin-sdk/packages/media-core/src/content-length.d.ts"
],
"@openclaw/media-core/file-name": [
"../../dist/plugin-sdk/packages/media-core/src/file-name.d.ts"
],
"@openclaw/media-core/inbound-path-policy": [
"../../dist/plugin-sdk/packages/media-core/src/inbound-path-policy.d.ts"
],
"@openclaw/media-core/inline-image-data-url": [
"../../dist/plugin-sdk/packages/media-core/src/inline-image-data-url.d.ts"
],
"@openclaw/media-core/media-source-url": [
"../../dist/plugin-sdk/packages/media-core/src/media-source-url.d.ts"
],
"@openclaw/media-core/mime": [
"../../dist/plugin-sdk/packages/media-core/src/mime.d.ts"
],
"@openclaw/media-core/read-byte-stream-with-limit": [
"../../dist/plugin-sdk/packages/media-core/src/read-byte-stream-with-limit.d.ts"
],
"@openclaw/media-core/*": [
"../../dist/plugin-sdk/packages/media-core/src/*.d.ts"
],
"@openclaw/normalization-core/record-coerce": [
"../../dist/plugin-sdk/packages/normalization-core/src/record-coerce.d.ts"
],
"@openclaw/normalization-core/string-coerce": [
"../../dist/plugin-sdk/packages/normalization-core/src/string-coerce.d.ts"
],
"@openclaw/normalization-core/*": [
"../../dist/plugin-sdk/packages/normalization-core/src/*.d.ts"
],
"@openclaw/acp-core": [
"../../dist/plugin-sdk/packages/acp-core/src/index.d.ts"
],
"@openclaw/acp-core/normalize-text": [
"../../dist/plugin-sdk/packages/acp-core/src/normalize-text.d.ts"
],
"@openclaw/acp-core/meta": [
"../../dist/plugin-sdk/packages/acp-core/src/meta.d.ts"
],
"@openclaw/acp-core/numeric-options": [
"../../dist/plugin-sdk/packages/acp-core/src/numeric-options.d.ts"
],
"@openclaw/acp-core/record-shared": [
"../../dist/plugin-sdk/packages/acp-core/src/record-shared.d.ts"
],
"@openclaw/acp-core/session": [
"../../dist/plugin-sdk/packages/acp-core/src/session.d.ts"
],
"@openclaw/acp-core/session-interaction-mode": [
"../../dist/plugin-sdk/packages/acp-core/src/session-interaction-mode.d.ts"
],
"@openclaw/acp-core/session-lineage-meta": [
"../../dist/plugin-sdk/packages/acp-core/src/session-lineage-meta.d.ts"
],
"@openclaw/acp-core/types": [
"../../dist/plugin-sdk/packages/acp-core/src/types.d.ts"
],
"@openclaw/acp-core/runtime/error-text": [
"../../dist/plugin-sdk/packages/acp-core/src/runtime/error-text.d.ts"
],
"@openclaw/acp-core/runtime/errors": [
"../../dist/plugin-sdk/packages/acp-core/src/runtime/errors.d.ts"
],
"@openclaw/acp-core/runtime/session-identifiers": [
"../../dist/plugin-sdk/packages/acp-core/src/runtime/session-identifiers.d.ts"
],
"@openclaw/acp-core/runtime/session-identity": [
"../../dist/plugin-sdk/packages/acp-core/src/runtime/session-identity.d.ts"
],
"@openclaw/acp-core/runtime/types": [
"../../dist/plugin-sdk/packages/acp-core/src/runtime/types.d.ts"
],
"@openclaw/acp-core/*": [
"../../dist/plugin-sdk/packages/acp-core/src/*.d.ts"
],
"@openclaw/terminal-core": [
"../../dist/plugin-sdk/packages/terminal-core/src/index.d.ts"
],
"@openclaw/terminal-core/ansi": [
"../../dist/plugin-sdk/packages/terminal-core/src/ansi.d.ts"
],
"@openclaw/terminal-core/decorative-emoji": [
"../../dist/plugin-sdk/packages/terminal-core/src/decorative-emoji.d.ts"
],
"@openclaw/terminal-core/health-style": [
"../../dist/plugin-sdk/packages/terminal-core/src/health-style.d.ts"
],
"@openclaw/terminal-core/links": [
"../../dist/plugin-sdk/packages/terminal-core/src/links.d.ts"
],
"@openclaw/terminal-core/note": [
"../../dist/plugin-sdk/packages/terminal-core/src/note.d.ts"
],
"@openclaw/terminal-core/osc-progress": [
"../../dist/plugin-sdk/packages/terminal-core/src/osc-progress.d.ts"
],
"@openclaw/terminal-core/palette": [
"../../dist/plugin-sdk/packages/terminal-core/src/palette.d.ts"
],
"@openclaw/terminal-core/progress-line": [
"../../dist/plugin-sdk/packages/terminal-core/src/progress-line.d.ts"
],
"@openclaw/terminal-core/prompt-select-styled": [
"../../dist/plugin-sdk/packages/terminal-core/src/prompt-select-styled.d.ts"
],
"@openclaw/terminal-core/prompt-select-styled-params": [
"../../dist/plugin-sdk/packages/terminal-core/src/prompt-select-styled-params.d.ts"
],
"@openclaw/terminal-core/prompt-style": [
"../../dist/plugin-sdk/packages/terminal-core/src/prompt-style.d.ts"
],
"@openclaw/terminal-core/restore": [
"../../dist/plugin-sdk/packages/terminal-core/src/restore.d.ts"
],
"@openclaw/terminal-core/safe-text": [
"../../dist/plugin-sdk/packages/terminal-core/src/safe-text.d.ts"
],
"@openclaw/terminal-core/stream-writer": [
"../../dist/plugin-sdk/packages/terminal-core/src/stream-writer.d.ts"
],
"@openclaw/terminal-core/table": [
"../../dist/plugin-sdk/packages/terminal-core/src/table.d.ts"
],
"@openclaw/terminal-core/terminal-link": [
"../../dist/plugin-sdk/packages/terminal-core/src/terminal-link.d.ts"
],
"@openclaw/terminal-core/theme": [
"../../dist/plugin-sdk/packages/terminal-core/src/theme.d.ts"
],
"@openclaw/terminal-core/*": [
"../../dist/plugin-sdk/packages/terminal-core/src/*.d.ts"
],
"@openclaw/*.js": [
"../../packages/plugin-sdk/dist/extensions/*.d.ts",
"../*"
],
"@openclaw/*": [
"../*"
],
"openclaw/plugin-sdk/qa-channel": [
"../../dist/plugin-sdk/src/plugin-sdk/qa-channel.d.ts"
],
"openclaw/plugin-sdk/qa-channel-protocol": [
"../../dist/plugin-sdk/src/plugin-sdk/qa-channel-protocol.d.ts"
],
"openclaw/plugin-sdk/qa-runtime": [
"../../dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts"
],
"@openclaw/plugin-sdk/*": [
"../../dist/plugin-sdk/*.d.ts"
],
"openclaw/plugin-sdk/cli-runtime": [
"../../dist/plugin-sdk/cli-runtime.d.ts"
],
"openclaw/plugin-sdk/provider-env-vars": [
"../../dist/plugin-sdk/provider-env-vars.d.ts"
],
"openclaw/plugin-sdk/provider-web-search-contract": [
"../../dist/plugin-sdk/provider-web-search-contract.d.ts"
],
"@openclaw/anthropic-vertex/api.js": [
"./.boundary-stubs/anthropic-vertex-api.d.ts"
],
"@openclaw/ollama/api.js": [
"./.boundary-stubs/ollama-api.d.ts"
],
"@openclaw/ollama/runtime-api.js": [
"./.boundary-stubs/ollama-runtime-api.d.ts"
],
"@openclaw/speech-core/runtime-api.js": [
"./.boundary-stubs/speech-core-runtime-api.d.ts"
]
}
},
"include": [
"./*.ts",
"./src/**/*.ts"
],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

176
extensions/xai/tts.test.ts Normal file
View File

@@ -0,0 +1,176 @@
// Xai tests cover tts plugin behavior.
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { isValidXaiTtsVoice, XAI_BASE_URL, XAI_TTS_VOICES, xaiTTS } from "./tts.js";
function createStreamingAudioResponse(params: {
chunkCount: number;
chunkSize: number;
byte: number;
}): { response: Response; getReadCount: () => number } {
let reads = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (reads >= params.chunkCount) {
controller.close();
return;
}
reads += 1;
controller.enqueue(new Uint8Array(params.chunkSize).fill(params.byte));
},
});
return {
response: new Response(stream, {
status: 200,
headers: { "Content-Type": "audio/mpeg" },
}),
getReadCount: () => reads,
};
}
describe("xai tts", () => {
const originalFetch = globalThis.fetch;
let ssrfMock: { mockRestore: () => void } | undefined;
beforeEach(() => {
ssrfMock = mockPinnedHostnameResolution();
});
afterEach(() => {
ssrfMock?.mockRestore();
ssrfMock = undefined;
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("isValidXaiTtsVoice", () => {
it("accepts all valid voices", () => {
for (const voice of XAI_TTS_VOICES) {
expect(isValidXaiTtsVoice(voice)).toBe(true);
}
});
it("rejects invalid voice names", () => {
expect(isValidXaiTtsVoice("invalid")).toBe(false);
expect(isValidXaiTtsVoice("")).toBe(false);
expect(isValidXaiTtsVoice("ALLOY")).toBe(false);
expect(isValidXaiTtsVoice("alloy ")).toBe(false);
expect(isValidXaiTtsVoice(" alloy")).toBe(false);
});
it("treats custom endpoints as permissive", () => {
expect(isValidXaiTtsVoice("grok-voice-custom", "https://custom.api.x.ai/v1")).toBe(true);
});
});
describe("xaiTTS diagnostics", () => {
it("includes parsed provider detail and request id for JSON API errors", async () => {
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
error: {
message: "Invalid API key",
type: "invalid_request_error",
code: "invalid_api_key",
},
}),
{
status: 401,
headers: {
"Content-Type": "application/json",
"x-request-id": "req_123",
},
},
),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expect(
xaiTTS({
text: "hello",
apiKey: "bad-key",
baseUrl: XAI_BASE_URL,
voiceId: "eve",
language: "en",
responseFormat: "mp3",
timeoutMs: 5_000,
}),
).rejects.toThrow(
"xAI TTS API error (401): Invalid API key [type=invalid_request_error, code=invalid_api_key] [request_id=req_123]",
);
});
it("sends an openclaw User-Agent on xAI TTS requests", async () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
const fetchMock = vi.fn(
async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(Buffer.from("audio-bytes"), {
status: 200,
headers: { "Content-Type": "audio/mpeg" },
}),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await xaiTTS({
text: "hello",
apiKey: "ok-key",
baseUrl: XAI_BASE_URL,
voiceId: "eve",
language: "en",
responseFormat: "mp3",
timeoutMs: 5_000,
});
const init = fetchMock.mock.calls.at(0)?.[1];
const headers = new Headers(init?.headers ?? {});
expect(headers.get("user-agent")).toBe("openclaw/2026.3.22");
expect(headers.get("authorization")).toBe("Bearer ok-key");
vi.unstubAllEnvs();
});
it("caps streamed audio responses instead of buffering oversized TTS output", async () => {
const streamed = createStreamingAudioResponse({
chunkCount: 20,
chunkSize: 1024,
byte: 121,
});
const fetchMock = vi.fn(async () => streamed.response);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expect(
xaiTTS({
text: "hello",
apiKey: "ok-key",
baseUrl: XAI_BASE_URL,
voiceId: "eve",
language: "en",
responseFormat: "mp3",
timeoutMs: 5_000,
maxBytes: 2048,
}),
).rejects.toThrow("xAI TTS audio response exceeds 2048 bytes");
expect(streamed.getReadCount()).toBeLessThan(20);
});
it("falls back to raw body text when the error body is non-JSON", async () => {
const fetchMock = vi.fn(
async () => new Response("temporary upstream outage", { status: 503 }),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expect(
xaiTTS({
text: "hello",
apiKey: "test-key",
baseUrl: XAI_BASE_URL,
voiceId: "eve",
language: "en",
responseFormat: "mp3",
timeoutMs: 5_000,
}),
).rejects.toThrow("xAI TTS API error (503): temporary upstream outage");
});
});
});

105
extensions/xai/tts.ts Normal file
View File

@@ -0,0 +1,105 @@
// Xai plugin module implements tts behavior.
import { assertOkOrThrowProviderError, postJsonRequest } from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { trimToUndefined } from "openclaw/plugin-sdk/speech";
import { XAI_BASE_URL } from "./api.js";
import { xaiUserAgentHeaderFor } from "./src/xai-user-agent.js";
export { XAI_BASE_URL };
const DEFAULT_TTS_MAX_BYTES = 16 * 1024 * 1024;
export const XAI_TTS_VOICES = ["eve", "ara", "rex", "sal", "leo", "una"] as const;
type XaiTtsVoice = (typeof XAI_TTS_VOICES)[number];
export function normalizeXaiTtsBaseUrl(baseUrl?: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return XAI_BASE_URL;
}
return trimmed.replace(/\/+$/, "");
}
export function isValidXaiTtsVoice(voice: string, baseUrl?: string): voice is XaiTtsVoice {
const normalizedBase = normalizeXaiTtsBaseUrl(baseUrl ?? process.env.XAI_BASE_URL);
const host = normalizedBase.includes("://") ? new URL(normalizedBase).hostname : normalizedBase;
const isNative = host === "api.x.ai";
if (!isNative) {
return true;
}
return XAI_TTS_VOICES.includes(voice as XaiTtsVoice);
}
export function normalizeXaiLanguageCode(value: unknown): string | undefined {
const trimmed = trimToUndefined(value);
if (!trimmed) {
return undefined;
}
const normalized = trimmed.toLowerCase();
if (normalized === "auto" || /^[a-z]{2,3}(?:-[a-z]{2,4})?$/.test(normalized)) {
return normalized;
}
throw new Error(
`xAI language must be "auto" or a BCP-47 tag (e.g. "en", "pt-br", "zh-cn"); got: ${normalized}`,
);
}
export async function xaiTTS(params: {
text: string;
apiKey: string;
baseUrl: string;
voiceId: string;
language?: string;
speed?: number;
responseFormat?: "mp3" | "wav" | "pcm" | "mulaw" | "alaw";
timeoutMs: number;
maxBytes?: number;
}): Promise<Buffer> {
const {
text,
apiKey,
baseUrl,
voiceId,
language: rawLanguage,
speed,
responseFormat = "mp3",
timeoutMs,
maxBytes = DEFAULT_TTS_MAX_BYTES,
} = params;
const language = normalizeXaiLanguageCode(rawLanguage) ?? "en";
if (!isValidXaiTtsVoice(voiceId, baseUrl)) {
throw new Error(`Invalid voice: ${voiceId}`);
}
const ttsBaseUrl = normalizeXaiTtsBaseUrl(baseUrl);
const { response, release } = await postJsonRequest({
url: `${ttsBaseUrl}/tts`,
headers: new Headers({
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...xaiUserAgentHeaderFor(ttsBaseUrl),
}),
body: {
text,
voice_id: voiceId,
language,
output_format: {
codec: responseFormat,
},
...(speed != null && { speed }),
},
timeoutMs,
fetchFn: fetch,
auditContext: "xai tts",
});
try {
await assertOkOrThrowProviderError(response, "xAI TTS API error");
return await readResponseWithLimit(response, maxBytes, {
onOverflow: ({ maxBytes: maxBytesLocal }) =>
new Error(`xAI TTS audio response exceeds ${maxBytesLocal} bytes`),
});
} finally {
await release();
}
}

View File

@@ -0,0 +1,617 @@
// Xai tests cover video generation provider plugin behavior.
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
} from "openclaw/plugin-sdk/provider-http-test-mocks";
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const { postJsonRequestMock, fetchWithTimeoutMock, readProviderJsonResponseMock } =
getProviderHttpMocks();
let buildXaiVideoGenerationProvider: typeof import("./video-generation-provider.js").buildXaiVideoGenerationProvider;
beforeAll(async () => {
({ buildXaiVideoGenerationProvider } = await import("./video-generation-provider.js"));
});
installProviderHttpMockCleanup();
beforeEach(() => {
readProviderJsonResponseMock.mockImplementation(async <T>(response: Response, label: string) => {
const maxBytes = 16 * 1024 * 1024;
if (!response.body) {
try {
return (await response.json()) as T;
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
throw new Error(`${label}: JSON response exceeds ${maxBytes} bytes`);
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const body = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return JSON.parse(new TextDecoder().decode(body)) as T;
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
});
});
function requirePostJsonCall(index = 0): {
url?: string;
body?: Record<string, unknown>;
headers?: Headers;
} {
const params = (postJsonRequestMock.mock.calls as unknown as Array<[unknown]>)[index]?.[0] as
| {
url?: string;
body?: Record<string, unknown>;
headers?: Headers;
}
| undefined;
if (!params) {
throw new Error(`Expected postJsonRequest call ${index}`);
}
return params;
}
function requireFetchInitCall(index: number): {
url?: string;
init?: { method?: string };
timeoutMs?: number;
} {
const call = (
fetchWithTimeoutMock.mock.calls as unknown as Array<[string, { method?: string }, number]>
)[index];
if (!call) {
throw new Error(`Expected fetchWithTimeout call ${index}`);
}
return {
url: call[0],
init: call[1],
timeoutMs: call[2],
};
}
function streamedVideoResponse(bytes: string, contentType = "video/mp4"): Response {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(bytes));
controller.close();
},
}),
{ headers: { "content-type": contentType } },
);
}
function streamedJsonResponse(payload: unknown): Response {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(JSON.stringify(payload)));
controller.close();
},
}),
{ headers: { "content-type": "application/json" } },
);
}
// Drives an unbounded JSON body (>16 MiB, no Content-Length) so the bounded
// reader has to cancel the stream instead of buffering it all. The 1 MiB
// chunks are emitted lazily on `pull`, and a hard ceiling guards the test from
// hanging if the reader ever fails to cancel.
function oversizedJsonResponse(): {
response: Response;
state: { canceled: boolean; enqueuedBytes: number };
} {
const state = { canceled: false, enqueuedBytes: 0 };
const chunk = 1024 * 1024;
// 64 MiB ceiling: 4x the 16 MiB cap, so the bounded reader must cancel long
// before we run out of chunks.
const maxChunks = 64;
let emitted = 0;
const response = new Response(
new ReadableStream({
pull(controller) {
if (emitted >= maxChunks) {
controller.close();
return;
}
emitted += 1;
state.enqueuedBytes += chunk;
controller.enqueue(new Uint8Array(chunk));
},
cancel() {
state.canceled = true;
},
}),
{ headers: { "content-type": "application/json" } },
);
return { response, state };
}
describe("xai video generation provider", () => {
it("declares explicit mode capabilities", () => {
expectExplicitVideoGenerationCapabilities(buildXaiVideoGenerationProvider());
});
it("creates, polls, and downloads a generated video", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req_123",
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_123",
status: "done",
video: { url: "https://cdn.x.ai/video.mp4" },
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/webm" }),
arrayBuffer: async () => Buffer.from("webm-bytes"),
});
const provider = buildXaiVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "A tiny robot crab crossing a moonlit tide pool",
cfg: {},
durationSeconds: 6,
aspectRatio: "16:9",
resolution: "720P",
});
const createRequest = requirePostJsonCall();
expect(createRequest.url).toBe("https://api.x.ai/v1/videos/generations");
expect(createRequest.body?.model).toBe("grok-imagine-video");
expect(createRequest.body?.prompt).toBe("A tiny robot crab crossing a moonlit tide pool");
expect(createRequest.body?.duration).toBe(6);
expect(createRequest.body?.aspect_ratio).toBe("16:9");
expect(createRequest.body?.resolution).toBe("720p");
const pollRequest = requireFetchInitCall(0);
expect(pollRequest.url).toBe("https://api.x.ai/v1/videos/req_123");
expect(pollRequest.init?.method).toBe("GET");
expect(provider.defaultTimeoutMs).toBe(600_000);
expect(pollRequest.timeoutMs).toBe(600_000);
expect(result.videos[0]?.mimeType).toBe("video/webm");
expect(result.videos[0]?.fileName).toBe("video-1.webm");
expect(result.metadata?.requestId).toBe("req_123");
expect(result.metadata?.mode).toBe("generate");
});
it("rejects generated video downloads that exceed the configured media cap", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ request_id: "req_too_large" }),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_too_large",
status: "done",
video: { url: "https://cdn.x.ai/too-large.mp4" },
}),
})
.mockResolvedValueOnce(streamedVideoResponse("too-large"));
const provider = buildXaiVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "short video",
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
}),
).rejects.toThrow("xAI generated video download exceeds 1 bytes");
});
it("bounds an unbounded successful xAI create JSON body and cancels the stream", async () => {
const oversized = oversizedJsonResponse();
postJsonRequestMock.mockResolvedValue({
response: oversized.response,
release: vi.fn(async () => {}),
});
const provider = buildXaiVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "oversized create body",
cfg: {},
}),
).rejects.toThrow("xAI video generation response: JSON response exceeds 16777216 bytes");
// The bounded reader cancelled the stream rather than buffering the whole
// body, and stopped reading well before the 64 MiB ceiling.
expect(oversized.state.canceled).toBe(true);
expect(oversized.state.enqueuedBytes).toBeLessThan(64 * 1024 * 1024);
});
it("bounds an unbounded successful xAI poll JSON body and cancels the stream", async () => {
const oversized = oversizedJsonResponse();
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({ request_id: "req_poll_oversized" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce(oversized.response);
const provider = buildXaiVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "oversized poll body",
cfg: {},
}),
).rejects.toThrow("xAI video generation response: JSON response exceeds 16777216 bytes");
expect(oversized.state.canceled).toBe(true);
expect(oversized.state.enqueuedBytes).toBeLessThan(64 * 1024 * 1024);
});
it("wraps malformed successful xAI create responses", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => [],
},
release: vi.fn(async () => {}),
});
const provider = buildXaiVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "bad shape",
cfg: {},
}),
).rejects.toThrow("xAI video generation response malformed");
});
it("wraps non-JSON successful xAI create responses", async () => {
postJsonRequestMock.mockResolvedValue({
response: new Response("<html>Unexpected token < in JSON</html>", {
headers: { "content-type": "text/html" },
}),
release: vi.fn(async () => {}),
});
const provider = buildXaiVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "html body",
cfg: {},
}),
).rejects.toThrow("xAI video generation response malformed");
});
it("treats unknown xAI poll statuses as continue-polling and returns when terminal", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ request_id: "req_unknown_then_done" }),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_unknown_then_done",
status: "almost_done",
}),
})
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_unknown_then_done",
status: "submitted",
}),
})
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_unknown_then_done",
status: "done",
video: { url: "https://cdn.x.ai/eventual.mp4" },
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("mp4"),
});
const provider = buildXaiVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "unknown then done",
cfg: {},
});
expect(result.metadata?.requestId).toBe("req_unknown_then_done");
expect(result.metadata?.status).toBe("done");
});
it("treats `cancelled` as a terminal failure", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ request_id: "req_cancelled" }),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
request_id: "req_cancelled",
status: "cancelled",
}),
});
const provider = buildXaiVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "cancelled",
cfg: {},
}),
).rejects.toThrow("xAI video generation cancelled");
});
it("rejects completed xAI poll responses without output URLs as malformed", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ request_id: "req_no_video" }),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
request_id: "req_no_video",
status: "done",
video: {},
}),
});
const provider = buildXaiVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "missing video",
cfg: {},
}),
).rejects.toThrow("xAI video generation response malformed");
});
it("normalizes the xAI 'pending' poll status to 'processing' and keeps polling until done", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req_pending",
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
// First poll: in-progress payload mirroring xAI's real shape
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_pending",
status: "pending",
progress: 42,
}),
})
// Second poll: complete
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_pending",
status: "done",
video: { url: "https://cdn.x.ai/video-pending.mp4" },
progress: 100,
}),
})
// Download
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("mp4-bytes"),
});
const provider = buildXaiVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "Pending then done",
cfg: {},
durationSeconds: 6,
aspectRatio: "9:16",
resolution: "720P",
});
// Two poll calls (one pending, one done) — not throwing on "pending"
expect((fetchWithTimeoutMock.mock.calls as unknown[]).length).toBeGreaterThanOrEqual(2);
expect(result.videos[0]?.mimeType).toBe("video/mp4");
expect(result.metadata?.requestId).toBe("req_pending");
});
it("sends a single unroled image as xAI first-frame image-to-video", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req_image",
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_image",
status: "done",
video: { url: "https://cdn.x.ai/image-video.mp4" },
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("image-video-bytes"),
});
const provider = buildXaiVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "Animate this logo into a clean bumper",
cfg: {},
inputImages: [{ buffer: Buffer.from("png-bytes"), mimeType: "image/png" }],
});
const request = requirePostJsonCall();
expect(request.url).toBe("https://api.x.ai/v1/videos/generations");
const image = request.body?.image as { url?: string } | undefined;
expect(image?.url).toMatch(/^data:image\/png;base64,/u);
const body = request.body ?? {};
expect(body).not.toHaveProperty("reference_images");
expect(result.metadata?.mode).toBe("generate");
});
it("sends reference_image roles through xAI reference_images mode", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req_refs",
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_refs",
status: "done",
video: { url: "https://cdn.x.ai/reference-video.mp4" },
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("reference-video-bytes"),
});
const provider = buildXaiVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "Make a cinematic brand vignette using these references",
cfg: {},
durationSeconds: 12,
aspectRatio: "9:16",
resolution: "720P",
inputImages: [
{ url: "https://example.com/subject.png", role: "reference_image" },
{ url: "https://example.com/style.png", role: "reference_image" },
],
});
const request = requirePostJsonCall();
expect(request.url).toBe("https://api.x.ai/v1/videos/generations");
expect(request.body?.reference_images).toEqual([
{ url: "https://example.com/subject.png" },
{ url: "https://example.com/style.png" },
]);
expect(request.body?.duration).toBe(10);
expect(request.body?.aspect_ratio).toBe("9:16");
expect(request.body?.resolution).toBe("720p");
const body = request.body ?? {};
expect(body).not.toHaveProperty("image");
expect(result.metadata?.mode).toBe("referenceToVideo");
});
it("rejects mixed xAI first-frame and reference-image roles", async () => {
const provider = buildXaiVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "Use both images",
cfg: {},
inputImages: [
{ url: "https://example.com/subject.png", role: "reference_image" },
{ url: "https://example.com/first-frame.png", role: "first_frame" },
],
}),
).rejects.toThrow(
"xAI reference-image video generation requires every image role to be reference_image.",
);
expect(postJsonRequestMock).not.toHaveBeenCalled();
});
it("routes video inputs to the extension endpoint when duration is set", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req_extend",
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_extend",
status: "done",
video: { url: "https://cdn.x.ai/extended.mp4" },
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("extended-bytes"),
});
const provider = buildXaiVideoGenerationProvider();
await provider.generateVideo({
provider: "xai",
model: "grok-imagine-video",
prompt: "Continue the shot into a neon alleyway",
cfg: {},
durationSeconds: 8,
inputVideos: [{ url: "https://example.com/input.mp4" }],
});
const request = requirePostJsonCall();
expect(request.url).toBe("https://api.x.ai/v1/videos/extensions");
expect(request.body?.video).toEqual({ url: "https://example.com/input.mp4" });
expect(request.body?.duration).toBe(8);
});
});

View File

@@ -0,0 +1,510 @@
// Xai provider module implements model/runtime integration.
import { toImageDataUrl } from "openclaw/plugin-sdk/image-generation";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
createProviderOperationTimeoutResolver,
fetchProviderDownloadResponse,
fetchProviderOperationResponse,
postJsonRequest,
readProviderJsonResponse,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
type ProviderOperationTimeoutMs,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
GeneratedVideoAsset,
VideoGenerationProvider,
VideoGenerationRequest,
} from "openclaw/plugin-sdk/video-generation";
const DEFAULT_XAI_VIDEO_BASE_URL = "https://api.x.ai/v1";
const DEFAULT_XAI_VIDEO_MODEL = "grok-imagine-video";
const DEFAULT_TIMEOUT_MS = 600_000;
const POLL_INTERVAL_MS = 5_000;
const MAX_POLL_ATTEMPTS = 120;
const XAI_VIDEO_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]);
const XAI_VIDEO_MALFORMED_RESPONSE = "xAI video generation response malformed";
// xAI documents these as the only meaningful values; everything else (queued,
// processing, submitted, pending, in_progress, ...) means "keep polling".
const XAI_VIDEO_TERMINAL_FAILURE_STATUSES = new Set(["failed", "error", "expired", "cancelled"]);
const XAI_VIDEO_DEFAULT_DURATION_SECONDS = 8;
const XAI_VIDEO_DEFAULT_ASPECT_RATIO = "16:9";
const XAI_VIDEO_DEFAULT_RESOLUTION = "720p";
const DEFAULT_GENERATED_VIDEO_MAX_BYTES = 16 * 1024 * 1024;
type XaiVideoCreateResponse = {
request_id?: string;
error?: {
code?: string;
message?: string;
} | null;
};
type XaiVideoStatusResponse = {
request_id?: string;
// Free-form: xAI returns whatever string it wants here. The caller decides
// which strings are terminal vs continue-polling.
status: string;
video?: {
url?: string;
} | null;
error?: {
code?: string;
message?: string;
} | null;
};
type VideoGenerationSourceInput = {
url?: string;
buffer?: Buffer;
mimeType?: string;
role?: string;
};
async function readXaiVideoJson(response: Response): Promise<Record<string, unknown>> {
let payload: unknown;
try {
payload = await readProviderJsonResponse<unknown>(response, "xAI video generation response");
} catch (error) {
if (error instanceof Error && error.message.endsWith(": malformed JSON response")) {
throw new Error(XAI_VIDEO_MALFORMED_RESPONSE, { cause: error });
}
throw error;
}
if (!isRecord(payload)) {
throw new Error(XAI_VIDEO_MALFORMED_RESPONSE);
}
return payload;
}
function xaiErrorMessage(payload: Record<string, unknown>): string | undefined {
const error = payload.error;
if (error === undefined || error === null) {
return undefined;
}
if (!isRecord(error)) {
throw new Error(XAI_VIDEO_MALFORMED_RESPONSE);
}
return normalizeOptionalString(error.message);
}
function readXaiCreateResponse(payload: Record<string, unknown>): XaiVideoCreateResponse {
return {
request_id: normalizeOptionalString(payload.request_id),
error: xaiErrorMessage(payload) ? { message: xaiErrorMessage(payload) } : null,
};
}
function readXaiStatusResponse(payload: Record<string, unknown>): XaiVideoStatusResponse {
const video = payload.video;
if (video !== undefined && video !== null && !isRecord(video)) {
throw new Error(XAI_VIDEO_MALFORMED_RESPONSE);
}
return {
request_id: normalizeOptionalString(payload.request_id),
status: normalizeOptionalString(payload.status) ?? "",
video: isRecord(video) ? { url: normalizeOptionalString(video.url) } : null,
error: xaiErrorMessage(payload) ? { message: xaiErrorMessage(payload) } : null,
};
}
function resolveXaiVideoBaseUrl(req: VideoGenerationRequest): string {
return (
normalizeOptionalString(req.cfg?.models?.providers?.xai?.baseUrl) ?? DEFAULT_XAI_VIDEO_BASE_URL
);
}
function resolveGeneratedVideoMaxBytes(req: VideoGenerationRequest): number {
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * 1024 * 1024);
}
return DEFAULT_GENERATED_VIDEO_MAX_BYTES;
}
function resolveImageUrl(input: VideoGenerationSourceInput | undefined): string | undefined {
if (!input) {
return undefined;
}
const inputUrl = normalizeOptionalString(input.url);
if (inputUrl) {
return inputUrl;
}
if (!input.buffer) {
throw new Error("xAI image-to-video input is missing image data.");
}
return toImageDataUrl({ ...input, buffer: input.buffer, defaultMimeType: "image/png" });
}
function resolveRequiredImageUrl(input: VideoGenerationSourceInput): string {
const imageUrl = resolveImageUrl(input);
if (!imageUrl) {
throw new Error("xAI image-to-video input is missing image data.");
}
return imageUrl;
}
function isReferenceImage(input: VideoGenerationSourceInput): boolean {
return normalizeOptionalString(input.role)?.toLowerCase() === "reference_image";
}
function resolveInputVideoUrl(input: VideoGenerationSourceInput | undefined): string | undefined {
if (!input) {
return undefined;
}
const url = normalizeOptionalString(input.url);
if (url) {
return url;
}
if (input.buffer) {
throw new Error("xAI video editing currently requires a remote mp4 URL input.");
}
throw new Error("xAI video editing input is missing video data.");
}
function resolveDurationSeconds(params: {
durationSeconds?: number;
min?: number;
max?: number;
}): number | undefined {
if (typeof params.durationSeconds !== "number" || !Number.isFinite(params.durationSeconds)) {
return undefined;
}
const rounded = Math.round(params.durationSeconds);
return Math.max(params.min ?? 1, Math.min(params.max ?? 15, rounded));
}
function resolveAspectRatio(value: string | undefined): string | undefined {
const trimmed = normalizeOptionalString(value);
if (!trimmed || !XAI_VIDEO_ASPECT_RATIOS.has(trimmed)) {
return undefined;
}
return trimmed;
}
function resolveResolution(value: string | undefined): "480p" | "720p" | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (normalized === "480p") {
return "480p";
}
if (normalized === "720p" || normalized === "1080p") {
return "720p";
}
return undefined;
}
function resolveXaiVideoMode(
req: VideoGenerationRequest,
): "generate" | "referenceToVideo" | "edit" | "extend" {
const hasVideoInput = (req.inputVideos?.length ?? 0) > 0;
if (!hasVideoInput && (req.inputImages ?? []).some(isReferenceImage)) {
return "referenceToVideo";
}
if (!hasVideoInput) {
return "generate";
}
return typeof resolveDurationSeconds({
durationSeconds: req.durationSeconds,
min: 2,
max: 10,
}) === "number"
? "extend"
: "edit";
}
function buildCreateBody(req: VideoGenerationRequest): Record<string, unknown> {
const inputImages = req.inputImages ?? [];
const hasReferenceImages = inputImages.some(isReferenceImage);
if (hasReferenceImages && !inputImages.every(isReferenceImage)) {
throw new Error(
"xAI reference-image video generation requires every image role to be reference_image.",
);
}
if (!hasReferenceImages && inputImages.length > 1) {
throw new Error("xAI image-to-video generation supports at most one first-frame image.");
}
if (hasReferenceImages && inputImages.length > 7) {
throw new Error("xAI reference-image video generation supports at most 7 reference images.");
}
if ((req.inputVideos?.length ?? 0) > 1) {
throw new Error("xAI video generation supports at most one input video.");
}
if ((req.inputImages?.length ?? 0) > 0 && (req.inputVideos?.length ?? 0) > 0) {
throw new Error("xAI video generation does not support image and video inputs together.");
}
const mode = resolveXaiVideoMode(req);
const body: Record<string, unknown> = {
model: normalizeOptionalString(req.model) ?? DEFAULT_XAI_VIDEO_MODEL,
prompt: req.prompt,
};
if (mode === "generate") {
const imageUrl = resolveImageUrl(req.inputImages?.[0]);
if (imageUrl) {
body.image = { url: imageUrl };
}
body.duration =
resolveDurationSeconds({
durationSeconds: req.durationSeconds,
min: 1,
max: 15,
}) ?? XAI_VIDEO_DEFAULT_DURATION_SECONDS;
body.aspect_ratio = resolveAspectRatio(req.aspectRatio) ?? XAI_VIDEO_DEFAULT_ASPECT_RATIO;
body.resolution = resolveResolution(req.resolution) ?? XAI_VIDEO_DEFAULT_RESOLUTION;
return body;
}
if (mode === "referenceToVideo") {
body.reference_images = inputImages.map((image) => ({ url: resolveRequiredImageUrl(image) }));
body.duration =
resolveDurationSeconds({
durationSeconds: req.durationSeconds,
min: 1,
max: 10,
}) ?? XAI_VIDEO_DEFAULT_DURATION_SECONDS;
body.aspect_ratio = resolveAspectRatio(req.aspectRatio) ?? XAI_VIDEO_DEFAULT_ASPECT_RATIO;
body.resolution = resolveResolution(req.resolution) ?? XAI_VIDEO_DEFAULT_RESOLUTION;
return body;
}
body.video = { url: resolveInputVideoUrl(req.inputVideos?.[0]) };
if (mode === "extend") {
const duration = resolveDurationSeconds({
durationSeconds: req.durationSeconds,
min: 2,
max: 10,
});
if (typeof duration === "number") {
body.duration = duration;
}
}
return body;
}
function resolveCreateEndpoint(req: VideoGenerationRequest): string {
switch (resolveXaiVideoMode(req)) {
case "edit":
return "/videos/edits";
case "extend":
return "/videos/extensions";
default:
return "/videos/generations";
}
}
async function pollXaiVideo(params: {
requestId: string;
headers: Headers;
timeoutMs?: number;
baseUrl: string;
fetchFn: typeof fetch;
}): Promise<XaiVideoStatusResponse> {
const deadline = createProviderOperationDeadline({
timeoutMs: params.timeoutMs,
label: `xAI video generation request ${params.requestId}`,
});
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchProviderOperationResponse({
stage: "poll",
url: `${params.baseUrl}/videos/${params.requestId}`,
init: {
method: "GET",
headers: params.headers,
},
timeoutMs: createProviderOperationTimeoutResolver({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn: params.fetchFn,
provider: "xai",
requestFailedMessage: "xAI video status request failed",
});
const payload = readXaiStatusResponse(await readXaiVideoJson(response));
const normalizedStatus = payload.status.toLowerCase();
if (normalizedStatus === "done") {
return payload;
}
if (XAI_VIDEO_TERMINAL_FAILURE_STATUSES.has(normalizedStatus)) {
throw new Error(
normalizeOptionalString(payload.error?.message) ??
`xAI video generation ${normalizedStatus}`,
);
}
// Any other status (queued, processing, submitted, pending, in_progress,
// empty, …) is non-terminal: keep polling.
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
}
throw new Error(`xAI video generation task ${params.requestId} did not finish in time`);
}
async function downloadXaiVideo(params: {
url: string;
timeoutMs?: ProviderOperationTimeoutMs;
fetchFn: typeof fetch;
maxBytes: number;
}): Promise<GeneratedVideoAsset> {
const response = await fetchProviderDownloadResponse({
url: params.url,
init: { method: "GET" },
timeoutMs: params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
fetchFn: params.fetchFn,
provider: "xai",
requestFailedMessage: "xAI generated video download failed",
});
const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4";
const buffer = await readResponseWithLimit(response, params.maxBytes, {
onOverflow: ({ maxBytes }) =>
new Error(`xAI generated video download exceeds ${maxBytes} bytes`),
});
return {
buffer,
mimeType,
fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`,
};
}
export function buildXaiVideoGenerationProvider(): VideoGenerationProvider {
return {
id: "xai",
label: "xAI",
defaultModel: DEFAULT_XAI_VIDEO_MODEL,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
models: [DEFAULT_XAI_VIDEO_MODEL],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "xai",
agentDir,
}),
capabilities: {
generate: {
maxVideos: 1,
maxDurationSeconds: 15,
aspectRatios: [...XAI_VIDEO_ASPECT_RATIOS],
resolutions: ["480P", "720P"],
supportsAspectRatio: true,
supportsResolution: true,
},
imageToVideo: {
enabled: true,
maxVideos: 1,
maxInputImages: 7,
maxDurationSeconds: 15,
aspectRatios: [...XAI_VIDEO_ASPECT_RATIOS],
resolutions: ["480P", "720P"],
supportsAspectRatio: true,
supportsResolution: true,
},
videoToVideo: {
enabled: true,
maxVideos: 1,
maxInputVideos: 1,
maxDurationSeconds: 15,
supportsAspectRatio: true,
supportsResolution: true,
},
},
async generateVideo(req) {
const auth = await resolveApiKeyForProvider({
provider: "xai",
cfg: req.cfg,
agentDir: req.agentDir,
store: req.authStore,
});
if (!auth.apiKey) {
throw new Error("xAI API key missing");
}
const fetchFn = fetch;
const deadline = createProviderOperationDeadline({
timeoutMs: req.timeoutMs,
label: "xAI video generation",
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveXaiVideoBaseUrl(req),
defaultBaseUrl: DEFAULT_XAI_VIDEO_BASE_URL,
allowPrivateNetwork: false,
defaultHeaders: {
Authorization: `Bearer ${auth.apiKey}`,
"Content-Type": "application/json",
},
provider: "xai",
capability: "video",
transport: "http",
});
// Per-submit idempotency key prevents accidental double-charging if
// the request is replayed. Polls intentionally reuse `headers` without it.
const submitHeaders = new Headers(headers);
submitHeaders.set("x-idempotency-key", crypto.randomUUID());
const { response, release } = await postJsonRequest({
url: `${baseUrl}${resolveCreateEndpoint(req)}`,
headers: submitHeaders,
body: buildCreateBody(req),
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
});
try {
await assertOkOrThrowHttpError(response, "xAI video generation failed");
const submitted = readXaiCreateResponse(await readXaiVideoJson(response));
const requestId = normalizeOptionalString(submitted.request_id);
if (!requestId) {
throw new Error(
normalizeOptionalString(submitted.error?.message) ??
"xAI video generation response missing request_id",
);
}
const completed = await pollXaiVideo({
requestId,
headers,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
baseUrl,
fetchFn,
});
const videoUrl = normalizeOptionalString(completed.video?.url);
if (!videoUrl) {
throw new Error(XAI_VIDEO_MALFORMED_RESPONSE);
}
const video = await downloadXaiVideo({
url: videoUrl,
timeoutMs: createProviderOperationTimeoutResolver({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
maxBytes: resolveGeneratedVideoMaxBytes(req),
});
return {
videos: [video],
model: normalizeOptionalString(req.model) ?? DEFAULT_XAI_VIDEO_MODEL,
metadata: {
requestId,
status: completed.status,
videoUrl,
mode: resolveXaiVideoMode(req),
},
};
} finally {
await release();
}
},
};
}

View File

@@ -0,0 +1,10 @@
// Xai API module exposes the plugin public contract.
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { buildXaiWebSearchProviderBase } from "./web-search-provider-shared.js";
export function createXaiWebSearchProvider(): WebSearchProviderPlugin {
return {
...buildXaiWebSearchProviderBase(),
createTool: () => null,
};
}

View File

@@ -0,0 +1,32 @@
// Xai provider module implements model/runtime integration.
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
export const XAI_WEB_SEARCH_CREDENTIAL_PATH = "plugins.entries.xai.config.webSearch.apiKey";
export function buildXaiWebSearchProviderBase(): Omit<
WebSearchProviderPlugin,
"createTool" | "runSetup"
> {
return {
id: "grok",
label: "Grok (xAI)",
hint: "Uses xAI OAuth or API key · xAI web-grounded responses",
onboardingScopes: ["text-inference"],
credentialLabel: "xAI API key",
envVars: ["XAI_API_KEY"],
authProviderId: "xai",
placeholder: "xai-...",
signupUrl: "https://console.x.ai/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 30,
credentialPath: XAI_WEB_SEARCH_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: XAI_WEB_SEARCH_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "grok" },
configuredCredential: { pluginId: "xai" },
}),
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Xai plugin module implements web search behavior.
import type {
WebSearchProviderPlugin,
WebSearchProviderSetupContext,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { buildXaiWebSearchProviderBase } from "./web-search-provider-shared.js";
const loadXaiWebSearchProviderRuntime = createLazyRuntimeModule(
() => import("./src/web-search-provider.runtime.js"),
);
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,
},
},
additionalProperties: false,
} satisfies Record<string, unknown>;
async function runXaiSearchProviderSetup(
ctx: WebSearchProviderSetupContext,
): Promise<WebSearchProviderSetupContext["config"]> {
const runtime = await loadXaiWebSearchProviderRuntime();
return await runtime.runXaiSearchProviderSetup(ctx);
}
export function createXaiWebSearchProvider(): WebSearchProviderPlugin {
return {
...buildXaiWebSearchProviderBase(),
runSetup: runXaiSearchProviderSetup,
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 loadXaiWebSearchProviderRuntime();
return await executeXaiWebSearchProviderTool(ctx, args);
},
}),
};
}

View File

@@ -0,0 +1,52 @@
// Xai plugin module implements x search tool shared behavior.
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import { Type } from "typebox";
export function buildMissingXSearchApiKeyPayload() {
return {
error: "missing_xai_api_key",
message:
"x_search needs xAI credentials. Run `openclaw onboard --auth-choice xai-oauth` to sign in with Grok, run `openclaw onboard --auth-choice 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",
};
}
export function createXSearchToolDefinition(
execute: (toolCallId: string, args: Record<string, unknown>) => Promise<AgentToolResult<unknown>>,
) {
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:
"Natural-language instruction sent to the Grok X-search agent. Must be meaningful and non-empty.",
}),
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,
};
}

View File

@@ -0,0 +1,77 @@
// Xai tests cover x search plugin behavior.
import { isBillingErrorMessage } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { createXSearchTool } from "./x-search.js";
const liveEnabled =
process.env.OPENCLAW_LIVE_TEST === "1" && (process.env.XAI_API_KEY ?? "").trim().length > 0;
const describeLive = liveEnabled ? describe : describe.skip;
describeLive("xai x_search live", () => {
it("queries X through xAI Responses", async () => {
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
xSearch: {
enabled: true,
model: "grok-4-1-fast-non-reasoning",
maxTurns: 1,
timeoutSeconds: 60,
},
},
},
},
},
},
});
if (!tool) {
throw new Error("expected x_search tool to be registered");
}
let result: Awaited<ReturnType<typeof tool.execute>>;
try {
result = await tool.execute("x-search:live", {
query: "OpenClaw from:steipete",
to_date: "2026-03-28",
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (isBillingErrorMessage(message)) {
console.warn(`[xai:x-search:live] skip: billing drift: ${message}`);
return;
}
throw error;
}
const details = (result.details ?? {}) as {
provider?: string;
content?: string;
citations?: string[];
inlineCitations?: unknown[];
error?: string;
message?: string;
};
const errorMessage =
details.error && details.message
? `${details.error} ${details.message}`
: details.error || details.message || "";
if (isBillingErrorMessage(errorMessage)) {
console.warn(`[xai:x-search:live] skip: billing drift: ${errorMessage}`);
return;
}
expect(details.error, details.message).toBeUndefined();
expect(details.provider).toBe("xai");
expect(details.content?.trim().length ?? 0).toBeGreaterThan(0);
const citationCount =
(Array.isArray(details.citations) ? details.citations.length : 0) +
(Array.isArray(details.inlineCitations) ? details.inlineCitations.length : 0);
expect(citationCount).toBeGreaterThan(0);
}, 75_000);
});

View File

@@ -0,0 +1,518 @@
// Xai tests cover x search plugin behavior.
import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createXSearchTool } from "./x-search.js";
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
function installXSearchFetch(payload?: Record<string, unknown>) {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(
jsonResponse(
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"],
},
),
),
);
global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
}
function firstFetchCall(mockFetch: ReturnType<typeof installXSearchFetch>) {
const [call] = mockFetch.mock.calls;
if (!call) {
throw new Error("expected x_search fetch call");
}
return call;
}
function firstFetchUrl(mockFetch: ReturnType<typeof installXSearchFetch>) {
const [url] = firstFetchCall(mockFetch);
return String(url);
}
function firstFetchInit(mockFetch: ReturnType<typeof installXSearchFetch>): RequestInit {
const [, init] = firstFetchCall(mockFetch);
if (!init || typeof init !== "object" || Array.isArray(init)) {
throw new Error("expected x_search fetch init");
}
return init as RequestInit;
}
function firstAuthorizationHeader(mockFetch: ReturnType<typeof installXSearchFetch>) {
const headers = firstFetchInit(mockFetch).headers;
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
throw new Error("expected x_search request headers");
}
return (headers as Record<string, string>).Authorization;
}
function parseFirstRequestBody(mockFetch: ReturnType<typeof installXSearchFetch>) {
const requestBody = firstFetchInit(mockFetch).body;
return JSON.parse(typeof requestBody === "string" ? requestBody : "{}") as Record<
string,
unknown
>;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("xai x_search tool", () => {
it("describes query as the required instruction for the Grok X-search agent", () => {
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
},
},
},
},
},
},
});
const parameters = tool?.parameters as
| { properties?: { query?: { description?: string } } }
| undefined;
const queryDescription = parameters?.properties?.query?.description;
expect(queryDescription).toContain("Natural-language instruction");
expect(queryDescription).toContain("Grok X-search agent");
expect(queryDescription).toContain("meaningful and non-empty");
expect(queryDescription).not.toContain("allowed_x_handles");
});
it("enables x_search when runtime config carries the shared xAI key", () => {
const tool = createXSearchTool({
config: {},
runtimeConfig: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "x-search-runtime-key", // pragma: allowlist secret
},
},
},
},
},
},
});
expect(tool?.name).toBe("x_search");
});
it("enables x_search from an xAI auth profile and uses it for requests", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {},
auth: {
hasAuthForProvider: (providerId) => providerId === "xai",
resolveApiKeyForProvider: async (providerId) =>
providerId === "xai" ? "xai-profile-key" : undefined, // pragma: allowlist secret
},
});
expect(tool?.name).toBe("x_search");
await tool?.execute?.("x-search:auth-profile", {
query: "auth profile search",
});
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer xai-profile-key");
});
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: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-config-test", // pragma: allowlist secret
},
xSearch: {
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(firstFetchUrl(mockFetch)).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("routes x_search through plugin-owned xSearch.baseUrl", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-config-test", // pragma: allowlist secret
},
xSearch: {
enabled: true,
baseUrl: "https://api.x.ai/xai-search/v1/",
},
},
},
},
},
},
});
await tool?.execute?.("x-search:plugin-base-url", {
query: "base url route",
});
expect(firstFetchUrl(mockFetch)).toBe("https://api.x.ai/xai-search/v1/responses");
});
it("falls back to Grok web search baseUrl for x_search", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
tools: {
web: {
search: {
grok: {
apiKey: "xai-legacy-key", // pragma: allowlist secret
baseUrl: "https://api.x.ai/legacy/v1/",
},
},
},
},
},
});
await tool?.execute?.("x-search:legacy-grok-base-url", {
query: "legacy base url route",
});
expect(firstFetchUrl(mockFetch)).toBe("https://api.x.ai/legacy/v1/responses");
});
it("shares plugin webSearch.baseUrl with x_search when xSearch.baseUrl is unset", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
baseUrl: "https://api.x.ai/shared/v1/",
},
xSearch: {
enabled: true,
},
},
},
},
},
},
});
await tool?.execute?.("x-search:web-search-base-url", {
query: "shared base url route",
});
expect(firstFetchUrl(mockFetch)).toBe("https://api.x.ai/shared/v1/responses");
});
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",
});
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer xai-plugin-key");
});
it("reports malformed x_search JSON as a provider error", async () => {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(
new Response("{ nope", {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
global.fetch = withFetchPreconnect(mockFetch);
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
},
xSearch: {
enabled: true,
},
},
},
},
},
},
});
await expect(
tool?.execute?.("x-search:malformed-json", {
query: "malformed x_search response probe",
}),
).rejects.toThrow("xAI X search failed: malformed JSON response");
});
it("rejects x_search success JSON without answer text", async () => {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(jsonResponse({ output: [] })),
);
global.fetch = withFetchPreconnect(mockFetch);
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
},
xSearch: {
enabled: true,
},
},
},
},
},
},
});
await expect(
tool?.execute?.("x-search:missing-text", {
query: "malformed x_search missing text probe",
}),
).rejects.toThrow("xAI X search failed: malformed JSON response");
});
it("prefers the active runtime config for shared xAI keys", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "X_SEARCH_KEY_REF" },
},
},
},
},
},
},
runtimeConfig: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "x-search-runtime-key", // pragma: allowlist secret
},
},
},
},
},
},
});
await tool?.execute?.("x-search:runtime-key", {
query: "runtime key search",
});
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer x-search-runtime-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",
});
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer xai-legacy-key");
});
it("uses migrated runtime auth when the source config still carries legacy x_search apiKey", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
tools: {
web: {
x_search: {
apiKey: "legacy-x-search-key", // pragma: allowlist secret
enabled: true,
} as Record<string, unknown>,
},
},
},
runtimeConfig: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "migrated-runtime-key", // pragma: allowlist secret
},
},
},
},
},
},
});
await tool?.execute?.("x-search:migrated-runtime-key", {
query: "migrated runtime auth",
});
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer migrated-runtime-key");
});
it("rejects invalid date ordering before calling xAI", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
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();
});
});

231
extensions/xai/x-search.ts Normal file
View File

@@ -0,0 +1,231 @@
// Xai plugin module implements x search behavior.
import {
jsonResult,
readCache,
readStringArrayParam,
readStringParam,
resolveCacheTtlMs,
resolveTimeoutSeconds,
writeCache,
} from "openclaw/plugin-sdk/provider-web-search";
import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
import {
isXaiToolEnabled,
resolveXaiToolApiKeyWithAuth,
type XaiToolAuthContext,
} from "./src/tool-auth-shared.js";
import { resolveEffectiveXSearchConfig } from "./src/x-search-config.js";
import {
buildXaiXSearchPayload,
requestXaiXSearch,
resolveXaiXSearchEndpoint,
resolveXaiXSearchInlineCitations,
resolveXaiXSearchMaxTurns,
resolveXaiXSearchModel,
type XaiXSearchOptions,
} from "./src/x-search-shared.js";
import {
buildMissingXSearchApiKeyPayload,
createXSearchToolDefinition,
} from "./x-search-tool-shared.js";
class PluginToolInputError extends Error {
constructor(message: string) {
super(message);
this.name = "ToolInputError";
}
}
const X_SEARCH_CACHE_KEY = Symbol.for("openclaw.xai.x-search.cache");
type XSearchCacheEntry = {
expiresAt: number;
insertedAt: number;
value: Record<string, unknown>;
};
function getSharedXSearchCache(): Map<string, XSearchCacheEntry> {
const root = globalThis as Record<PropertyKey, unknown>;
const existing = root[X_SEARCH_CACHE_KEY];
if (existing instanceof Map) {
return existing as Map<string, XSearchCacheEntry>;
}
const next = new Map<string, XSearchCacheEntry>();
root[X_SEARCH_CACHE_KEY] = next;
return next;
}
const X_SEARCH_CACHE = getSharedXSearchCache();
function resolveXSearchConfig(cfg?: unknown): Record<string, unknown> | undefined {
return resolveEffectiveXSearchConfig(cfg as never);
}
function resolveXSearchEnabled(params: {
cfg?: unknown;
config?: Record<string, unknown>;
runtimeConfig?: unknown;
auth?: XaiToolAuthContext;
}): boolean {
return isXaiToolEnabled({
enabled: params.config?.enabled as boolean | undefined,
runtimeConfig: params.runtimeConfig as never,
sourceConfig: params.cfg as never,
auth: params.auth,
});
}
async function resolveXSearchApiKey(params: {
sourceConfig?: unknown;
runtimeConfig?: unknown;
auth?: XaiToolAuthContext;
}): Promise<string | undefined> {
return await resolveXaiToolApiKeyWithAuth(params as never);
}
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 PluginToolInputError(`${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 PluginToolInputError(`${label} must be a valid calendar date`);
}
return trimmed;
}
function buildXSearchCacheKey(params: {
query: string;
model: string;
endpoint: string;
inlineCitations: boolean;
maxTurns?: number;
options: Omit<XaiXSearchOptions, "query">;
}) {
return JSON.stringify([
"x_search",
params.model,
params.endpoint,
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?: unknown;
runtimeConfig?: Record<string, unknown> | null;
auth?: XaiToolAuthContext;
}) {
const xSearchConfig = resolveXSearchConfig(options?.config);
const runtimeConfig = options?.runtimeConfig ?? getRuntimeConfigSnapshot();
if (
!resolveXSearchEnabled({
cfg: options?.config,
config: xSearchConfig,
runtimeConfig: runtimeConfig ?? undefined,
auth: options?.auth,
})
) {
return null;
}
return createXSearchToolDefinition(async (_toolCallId: string, args: Record<string, unknown>) => {
const apiKey = await resolveXSearchApiKey({
sourceConfig: options?.config,
runtimeConfig: runtimeConfig ?? undefined,
auth: options?.auth,
});
if (!apiKey) {
return jsonResult(buildMissingXSearchApiKeyPayload());
}
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 PluginToolInputError("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;
const model = resolveXaiXSearchModel(xSearchConfigRecord);
const endpoint = resolveXaiXSearchEndpoint(xSearchConfigRecord);
const inlineCitations = resolveXaiXSearchInlineCitations(xSearchConfigRecord);
const maxTurns = resolveXaiXSearchMaxTurns(xSearchConfigRecord);
const cacheKey = buildXSearchCacheKey({
query,
model,
endpoint,
inlineCitations,
maxTurns,
options: {
allowedXHandles,
excludedXHandles,
fromDate,
toDate,
enableImageUnderstanding: xSearchOptions.enableImageUnderstanding,
enableVideoUnderstanding: xSearchOptions.enableVideoUnderstanding,
},
});
const cached = readCache(X_SEARCH_CACHE, cacheKey);
if (cached) {
return jsonResult({ ...cached.value, cached: true });
}
const startedAt = Date.now();
const result = await requestXaiXSearch({
apiKey,
endpoint,
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(
X_SEARCH_CACHE,
cacheKey,
payload,
resolveCacheTtlMs(xSearchConfig?.cacheTtlMinutes, 15),
);
return jsonResult(payload);
});
}

View File

@@ -0,0 +1,574 @@
// Xai tests cover xai oauth plugin behavior.
import type { ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry";
import {
createRuntimeEnv,
createTestWizardPrompter,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import type { OAuthCredential } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createXaiDeviceCodeAuthMethod,
createXaiOAuthAuthMethod,
fetchXaiOAuthDiscovery,
isTrustedXaiOAuthEndpoint,
loginXaiDeviceCode,
refreshXaiOAuthCredential,
XAI_OAUTH_CLIENT_ID,
XAI_OAUTH_DISCOVERY_URL,
XAI_OAUTH_SCOPE,
} from "./xai-oauth.js";
function jsonResponse(value: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(value), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
function createJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${header}.${body}.signature`;
}
function requireStringBody(init: RequestInit | undefined): string {
if (typeof init?.body !== "string") {
throw new Error("expected request body to be a string");
}
return init.body;
}
function requestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.href;
}
return input.url;
}
describe("xAI OAuth", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.useRealTimers();
});
it("accepts only trusted xAI OAuth endpoints", () => {
expect(isTrustedXaiOAuthEndpoint("https://auth.x.ai/oauth2/token")).toBe(true);
expect(isTrustedXaiOAuthEndpoint("https://accounts.x.ai/oauth2/token")).toBe(true);
expect(isTrustedXaiOAuthEndpoint("http://auth.x.ai/oauth2/token")).toBe(false);
expect(isTrustedXaiOAuthEndpoint("https://x.ai.evil.test/oauth2/token")).toBe(false);
expect(isTrustedXaiOAuthEndpoint("not a url")).toBe(false);
});
it("keeps the public auth method named OAuth while using device code", () => {
const method = createXaiOAuthAuthMethod();
expect(method.id).toBe("oauth");
expect(method.kind).toBe("oauth");
expect(method.wizard?.choiceId).toBe("xai-oauth");
expect(method.wizard?.methodId).toBe("oauth");
});
it("preserves device-code as an explicit auth method alias", () => {
const method = createXaiDeviceCodeAuthMethod();
expect(method.id).toBe("device-code");
expect(method.kind).toBe("device_code");
expect(method.wizard?.choiceId).toBe("xai-device-code");
expect(method.wizard?.methodId).toBe("device-code");
expect(method.wizard?.assistantVisibility).toBe("manual-only");
});
it("validates discovered endpoints before using them", async () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
const fetchImpl = vi.fn<typeof fetch>(async () =>
jsonResponse({
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
token_endpoint: "https://auth.x.ai/oauth2/token",
}),
);
await expect(fetchXaiOAuthDiscovery({ fetchImpl })).resolves.toEqual({
tokenEndpoint: "https://auth.x.ai/oauth2/token",
});
const discoveryInit = fetchImpl.mock.calls.at(0)?.[1];
const discoveryHeaders = new Headers(discoveryInit?.headers ?? {});
expect(discoveryHeaders.get("user-agent")).toBe("openclaw/2026.3.22");
vi.unstubAllEnvs();
const poisonedFetch = vi.fn<typeof fetch>(async () =>
jsonResponse({
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
token_endpoint: "https://evil.test/oauth2/token",
}),
);
await expect(fetchXaiOAuthDiscovery({ fetchImpl: poisonedFetch })).rejects.toThrow(
"untrusted token endpoint",
);
});
it("refreshes with the cached token endpoint and preserves refresh fallback", async () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
const fetchImpl = vi.fn<typeof fetch>(async (_url, init) => {
expect(init?.method).toBe("POST");
expect(typeof init?.body).toBe("string");
const body = requireStringBody(init);
expect(body).toContain("grant_type=refresh_token");
expect(body).toContain(`client_id=${encodeURIComponent(XAI_OAUTH_CLIENT_ID)}`);
expect(body).toContain("refresh_token=refresh-1");
const headers = new Headers(init?.headers ?? {});
expect(headers.get("user-agent")).toBe("openclaw/2026.3.22");
return jsonResponse({
access_token: "access-2",
expires_in: 120,
});
});
const credential = {
type: "oauth",
provider: "xai",
access: "access-1",
refresh: "refresh-1",
expires: 100,
tokenEndpoint: "https://auth.x.ai/oauth2/token",
} satisfies OAuthCredential & { tokenEndpoint: string };
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
expect(fetchImpl).toHaveBeenCalledWith("https://auth.x.ai/oauth2/token", expect.any(Object));
expect(refreshed.access).toBe("access-2");
expect(refreshed.refresh).toBe("refresh-1");
expect(refreshed.expires).toBe(121_000);
});
it("rediscovers the current token endpoint for stale xAI OAuth credentials", async () => {
const fetchImpl = vi.fn<typeof fetch>(async (url, init) => {
if (requestUrl(url) === XAI_OAUTH_DISCOVERY_URL) {
expect(init?.method).toBeUndefined();
return jsonResponse({
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
token_endpoint: "https://auth.x.ai/oauth2/token",
});
}
expect(requestUrl(url)).toBe("https://auth.x.ai/oauth2/token");
expect(init?.method).toBe("POST");
expect(requireStringBody(init)).toContain("refresh_token=refresh-1");
return jsonResponse({
access_token: "access-2",
refresh_token: "refresh-2",
expires_in: 120,
});
});
const credential = {
type: "oauth",
provider: "xai",
access: "access-1",
refresh: "refresh-1",
expires: 100,
tokenEndpoint: "https://auth.x.ai/oauth/token",
} satisfies OAuthCredential & { tokenEndpoint: string };
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(fetchImpl.mock.calls.map(([url]) => requestUrl(url))).toEqual([
XAI_OAUTH_DISCOVERY_URL,
"https://auth.x.ai/oauth2/token",
]);
expect(refreshed).toMatchObject({
access: "access-2",
refresh: "refresh-2",
tokenEndpoint: "https://auth.x.ai/oauth2/token",
});
});
it("does not reuse the stale xAI OAuth token endpoint when discovery fails", async () => {
const fetchImpl = vi.fn<typeof fetch>(async (url) => {
expect(requestUrl(url)).toBe(XAI_OAUTH_DISCOVERY_URL);
throw new Error("discovery unavailable");
});
const credential = {
type: "oauth",
provider: "xai",
access: "access-1",
refresh: "refresh-1",
expires: 100,
tokenEndpoint: "https://auth.x.ai/oauth/token",
} satisfies OAuthCredential & { tokenEndpoint: string };
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
"discovery unavailable",
);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("retries transient HTML refresh failures before succeeding", async () => {
vi.useFakeTimers();
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
new Response("<!DOCTYPE html><html><body>Attention Required! Cloudflare</body></html>", {
status: 403,
headers: {
"Content-Type": "text/html",
"cf-mitigated": "challenge",
},
}),
)
.mockResolvedValueOnce(
new Response("<!DOCTYPE html><html><body>Just a moment...</body></html>", {
status: 403,
headers: {
"Content-Type": "text/html",
},
}),
)
.mockResolvedValueOnce(
jsonResponse({
access_token: "access-2",
expires_in: 120,
}),
);
const credential = {
type: "oauth",
provider: "xai",
access: "access-1",
refresh: "refresh-1",
expires: 100,
tokenEndpoint: "https://auth.x.ai/oauth2/token",
} satisfies OAuthCredential & { tokenEndpoint: string };
const refresh = refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
await vi.advanceTimersByTimeAsync(250);
await vi.advanceTimersByTimeAsync(250);
const refreshed = await refresh;
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(refreshed.access).toBe("access-2");
expect(refreshed.refresh).toBe("refresh-1");
});
it("surfaces xAI Cloudflare refresh failures after retry exhaustion", async () => {
vi.useFakeTimers();
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(
"<!DOCTYPE html><html><head><title>Attention Required! | Cloudflare</title></head><body>You are unable to access x.ai</body></html>",
{
status: 403,
headers: {
"Content-Type": "text/html",
"cf-mitigated": "challenge",
},
},
),
);
const credential = {
type: "oauth",
provider: "xai",
access: "access-1",
refresh: "refresh-1",
expires: 100,
tokenEndpoint: "https://auth.x.ai/oauth2/token",
} satisfies OAuthCredential & { tokenEndpoint: string };
const refresh = refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
const expectation = expect(refresh).rejects.toThrow(
"xAI returned an HTML/Cloudflare challenge",
);
await vi.advanceTimersByTimeAsync(250);
await vi.advanceTimersByTimeAsync(250);
await expectation;
expect(fetchImpl).toHaveBeenCalledTimes(3);
});
it("does not retry terminal xAI OAuth refresh errors", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
jsonResponse(
{
error: "invalid_grant",
error_description: "Invalid or unknown refresh token",
},
{ status: 400 },
),
);
const credential = {
type: "oauth",
provider: "xai",
access: "access-1",
refresh: "refresh-1",
expires: 100,
tokenEndpoint: "https://auth.x.ai/oauth2/token",
} satisfies OAuthCredential & { tokenEndpoint: string };
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
"invalid_grant (Invalid or unknown refresh token)",
);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("does not retry refresh-token service failures", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
jsonResponse(
{
error: "server_error",
error_description: "try again later",
},
{ status: 503 },
),
);
const credential = {
type: "oauth",
provider: "xai",
access: "access-1",
refresh: "refresh-1",
expires: 100,
tokenEndpoint: "https://auth.x.ai/oauth2/token",
} satisfies OAuthCredential & { tokenEndpoint: string };
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
"server_error (try again later)",
);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("does not retry refresh on transport errors so a rotated refresh token is never resent", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () => {
throw new Error("socket hang up");
});
const credential = {
type: "oauth",
provider: "xai",
access: "access-1",
refresh: "refresh-1",
expires: 100,
tokenEndpoint: "https://auth.x.ai/oauth2/token",
} satisfies OAuthCredential & { tokenEndpoint: string };
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
"socket hang up",
);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("does not coerce partial xAI expires_in values", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
jsonResponse({
access_token: "access-2",
expires_in: "120s",
}),
);
const credential = {
type: "oauth",
provider: "xai",
access: "access-1",
refresh: "refresh-1",
expires: 100,
tokenEndpoint: "https://auth.x.ai/oauth2/token",
} satisfies OAuthCredential & { tokenEndpoint: string };
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
expect(refreshed.expires).toBe(100);
});
it("preserves the cached xAI expiry when token lifetimes overflow safe milliseconds", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
jsonResponse({
access_token: createJwt({ exp: Number.MAX_SAFE_INTEGER }),
expires_in: Number.MAX_SAFE_INTEGER,
}),
);
const credential = {
type: "oauth",
provider: "xai",
access: "access-1",
refresh: "refresh-1",
expires: 100,
tokenEndpoint: "https://auth.x.ai/oauth2/token",
} satisfies OAuthCredential & { tokenEndpoint: string };
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
expect(refreshed.expires).toBe(100);
});
it("ignores unsafe JWT expiry fallbacks from xAI access tokens", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
jsonResponse({
access_token: createJwt({ exp: Number.MAX_SAFE_INTEGER }),
}),
);
const credential = {
type: "oauth",
provider: "xai",
access: "access-1",
refresh: "refresh-1",
expires: 100,
tokenEndpoint: "https://auth.x.ai/oauth2/token",
} satisfies OAuthCredential & { tokenEndpoint: string };
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
expect(refreshed.expires).toBe(100);
});
it("logs in with xAI device code without a localhost callback", async () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
const progress = {
update: vi.fn(),
stop: vi.fn(),
};
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
device_authorization_endpoint: "https://auth.x.ai/oauth2/device/code",
token_endpoint: "https://auth.x.ai/oauth2/token",
}),
)
.mockResolvedValueOnce(
jsonResponse({
device_code: "device-code-1",
user_code: "ABCD-1234",
verification_uri: "https://accounts.x.ai/oauth2/device",
verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-1234",
expires_in: 900,
interval: 5,
}),
)
.mockResolvedValueOnce(
jsonResponse({
access_token: createJwt({ exp: 4, sub: "acct-1" }),
refresh_token: "refresh-1",
id_token: createJwt({
sub: "acct-1",
email: "dev@example.com",
name: "Dev User",
}),
expires_in: 120,
}),
);
vi.stubGlobal("fetch", fetchImpl);
const note = vi.fn<(message: string, title?: string) => Promise<void>>(async () => {});
const openUrl = vi.fn(async () => {});
const log = vi.fn();
const runtime = { ...createRuntimeEnv(), log };
const ctx: ProviderAuthContext = {
config: {},
isRemote: true,
openUrl,
prompter: createTestWizardPrompter({
progress: vi.fn(() => progress),
note,
}),
runtime,
oauth: {
createVpsAwareHandlers: () => {
throw new Error("unexpected VPS OAuth handler request");
},
},
};
const result = await loginXaiDeviceCode(ctx);
expect(openUrl).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(expect.stringContaining("ABCD-1234"), "xAI OAuth");
const remoteLog = log.mock.calls[0]?.[0];
expect(remoteLog).toContain("https://accounts.x.ai/oauth2/device");
expect(remoteLog).not.toContain("ABCD-1234");
const deviceRequest = fetchImpl.mock.calls[1]?.[1];
expect(deviceRequest?.method).toBe("POST");
const deviceBody = requireStringBody(deviceRequest);
expect(deviceBody).toContain(`client_id=${encodeURIComponent(XAI_OAUTH_CLIENT_ID)}`);
expect(deviceBody).toContain(`scope=${encodeURIComponent(XAI_OAUTH_SCOPE)}`);
const tokenRequest = fetchImpl.mock.calls[2]?.[1];
expect(tokenRequest?.method).toBe("POST");
const tokenBody = requireStringBody(tokenRequest);
expect(tokenBody).toContain(
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code",
);
expect(tokenBody).toContain("device_code=device-code-1");
expect(result.profiles[0]?.credential).toMatchObject({
type: "oauth",
provider: "xai",
refresh: "refresh-1",
email: "dev@example.com",
displayName: "Dev User",
tokenEndpoint: "https://auth.x.ai/oauth2/token",
deviceAuthorizationEndpoint: "https://auth.x.ai/oauth2/device/code",
issuer: "https://auth.x.ai",
authFlow: "device-code",
accountId: "acct-1",
access: expect.any(String),
});
expect(progress.update).toHaveBeenCalledWith("Waiting for xAI device authorization...");
expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete");
});
it("falls back for unsafe xAI device-code lifetime fields", async () => {
const progress = {
update: vi.fn(),
stop: vi.fn(),
};
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
device_authorization_endpoint: "https://auth.x.ai/oauth2/device/code",
token_endpoint: "https://auth.x.ai/oauth2/token",
}),
)
.mockResolvedValueOnce(
jsonResponse({
device_code: "device-code-1",
user_code: "ABCD-1234",
verification_uri: "https://accounts.x.ai/oauth2/device",
expires_in: Number.MAX_SAFE_INTEGER,
interval: Number.MAX_SAFE_INTEGER,
}),
)
.mockResolvedValueOnce(
jsonResponse({
access_token: "access-token",
refresh_token: "refresh-1",
expires_in: 120,
}),
);
vi.stubGlobal("fetch", fetchImpl);
const note = vi.fn<(message: string, title?: string) => Promise<void>>(async () => {});
const ctx: ProviderAuthContext = {
config: {},
isRemote: true,
openUrl: vi.fn(async () => {}),
prompter: createTestWizardPrompter({
progress: vi.fn(() => progress),
note,
}),
runtime: createRuntimeEnv(),
oauth: {
createVpsAwareHandlers: () => {
throw new Error("unexpected VPS OAuth handler request");
},
},
};
await loginXaiDeviceCode(ctx);
expect(note).toHaveBeenCalledWith(
expect.stringContaining("Code expires in 5 minutes."),
"xAI OAuth",
);
expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete");
});
});

708
extensions/xai/xai-oauth.ts Normal file
View File

@@ -0,0 +1,708 @@
// Xai plugin module implements xai oauth behavior.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
positiveSecondsToSafeMilliseconds,
resolveExpiresAtMsFromDurationSeconds,
resolveExpiresAtMsFromEpochSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import type { ProviderAuthContext, ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry";
import {
buildOauthProviderAuthResult,
toFormUrlEncoded,
type OAuthCredential,
type ProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js";
import { xaiUserAgent } from "./src/xai-user-agent.js";
const PROVIDER_ID = "xai";
export const XAI_OAUTH_METHOD_ID = "oauth";
export const XAI_OAUTH_CHOICE_ID = "xai-oauth";
export const XAI_DEVICE_CODE_METHOD_ID = "device-code";
export const XAI_DEVICE_CODE_CHOICE_ID = "xai-device-code";
export const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
export const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
export const XAI_OAUTH_ISSUER = "https://auth.x.ai";
export const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`;
const XAI_LEGACY_OAUTH_TOKEN_ENDPOINT = `${XAI_OAUTH_ISSUER}/oauth/token`;
const XAI_OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
const XAI_OAUTH_FETCH_TIMEOUT_MS = 30 * 1000;
const XAI_OAUTH_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
const XAI_OAUTH_REFRESH_MAX_ATTEMPTS = 3;
const XAI_OAUTH_REFRESH_RETRY_DELAY_MS = 250;
const XAI_DEVICE_CODE_DEFAULT_INTERVAL_MS = 5 * 1000;
const XAI_DEVICE_CODE_MIN_INTERVAL_MS = 1 * 1000;
const XAI_DEVICE_CODE_SLOW_DOWN_INCREMENT_MS = 5 * 1000;
const XAI_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
type XaiOAuthDiscovery = {
tokenEndpoint: string;
};
type XaiDeviceCodeDiscovery = {
deviceAuthorizationEndpoint: string;
tokenEndpoint: string;
};
type XaiOAuthTokenResponse = {
accessToken: string;
refreshToken?: string;
expires?: number;
idToken?: string;
};
type XaiOAuthIdentity = {
email?: string;
displayName?: string;
accountId?: string;
};
type XaiOAuthFetchOptions = {
fetchImpl?: typeof fetch;
now?: () => number;
};
type XaiDeviceCodeResponse = {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
expiresInMs: number;
intervalMs: number;
};
type XaiOAuthErrorResponse = {
error?: string;
errorDescription?: string;
};
type XaiOAuthResponseBody = {
json: unknown;
text: string;
};
function getFetchImpl(fetchImpl?: typeof fetch): typeof fetch {
return fetchImpl ?? fetch;
}
export function isTrustedXaiOAuthEndpoint(endpoint: string): boolean {
try {
const url = new URL(endpoint);
if (url.protocol !== "https:") {
return false;
}
return url.hostname === "x.ai" || url.hostname.endsWith(".x.ai");
} catch {
return false;
}
}
function requireTrustedXaiOAuthEndpoint(endpoint: string, label: string): string {
if (!isTrustedXaiOAuthEndpoint(endpoint)) {
throw new Error(`xAI OAuth discovery returned untrusted ${label}`);
}
return endpoint;
}
function readStringRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
async function readResponseBody(response: Response): Promise<XaiOAuthResponseBody> {
const buffer = await readResponseWithLimit(response, XAI_OAUTH_RESPONSE_MAX_BYTES, {
onOverflow: ({ maxBytes }) => new Error(`xAI OAuth response exceeds ${maxBytes} bytes`),
});
const text = new TextDecoder().decode(buffer);
let json: unknown;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return { json, text };
}
async function readJsonResponse(response: Response, context: string): Promise<unknown> {
const body = await readResponseBody(response);
if (!response.ok) {
const errorText =
readStringRecord(body.json).error_description ?? readStringRecord(body.json).error;
throw new Error(
`${context} failed (${response.status})${typeof errorText === "string" ? `: ${errorText}` : ""}`,
);
}
return body.json;
}
async function fetchXaiOAuthDiscoveryDocument(
options: XaiOAuthFetchOptions = {},
): Promise<Record<string, unknown>> {
const response = await getFetchImpl(options.fetchImpl)(XAI_OAUTH_DISCOVERY_URL, {
headers: {
Accept: "application/json",
"User-Agent": xaiUserAgent(),
},
signal: AbortSignal.timeout(XAI_OAUTH_FETCH_TIMEOUT_MS),
});
return readStringRecord(await readJsonResponse(response, "xAI OAuth discovery"));
}
export async function fetchXaiOAuthDiscovery(
options: XaiOAuthFetchOptions = {},
): Promise<XaiOAuthDiscovery> {
const json = await fetchXaiOAuthDiscoveryDocument(options);
const tokenEndpoint = json.token_endpoint;
if (typeof tokenEndpoint !== "string") {
throw new Error("xAI OAuth discovery response is missing the token endpoint");
}
return {
tokenEndpoint: requireTrustedXaiOAuthEndpoint(tokenEndpoint, "token endpoint"),
};
}
async function fetchXaiDeviceCodeDiscovery(
options: XaiOAuthFetchOptions = {},
): Promise<XaiDeviceCodeDiscovery> {
const json = await fetchXaiOAuthDiscoveryDocument(options);
const deviceAuthorizationEndpoint = json.device_authorization_endpoint;
const tokenEndpoint = json.token_endpoint;
if (typeof deviceAuthorizationEndpoint !== "string" || typeof tokenEndpoint !== "string") {
throw new Error("xAI OAuth discovery response is missing device code endpoints");
}
return {
deviceAuthorizationEndpoint: requireTrustedXaiOAuthEndpoint(
deviceAuthorizationEndpoint,
"device authorization endpoint",
),
tokenEndpoint: requireTrustedXaiOAuthEndpoint(tokenEndpoint, "token endpoint"),
};
}
function normalizeExpires(value: unknown, now: () => number): number | undefined {
return resolveExpiresAtMsFromDurationSeconds(value, { nowMs: now() });
}
function parseXaiOAuthTokenResponse(
value: unknown,
now: () => number,
options: { requireRefreshToken?: boolean } = {},
): XaiOAuthTokenResponse {
const json = readStringRecord(value);
const accessToken = json.access_token;
if (typeof accessToken !== "string" || accessToken.trim().length === 0) {
throw new Error("xAI OAuth token response is missing access_token");
}
const refreshToken =
typeof json.refresh_token === "string" && json.refresh_token.trim().length > 0
? json.refresh_token
: undefined;
if (options.requireRefreshToken && !refreshToken) {
throw new Error(
"xAI OAuth token response is missing refresh_token. Re-run the login; if the issue persists, the OAuth client is not configured to issue refresh tokens (commonly because the offline_access scope was rejected).",
);
}
const idToken =
typeof json.id_token === "string" && json.id_token.trim().length > 0
? json.id_token
: undefined;
// RFC 6749 expires_in preferred; access-token JWT exp is the only legitimate
// fallback for an access-token expiry — id_token exp reflects the OIDC
// session, not the access token, and may extend it past actual expiry.
const expires = normalizeExpires(json.expires_in, now) ?? deriveExpiresFromJwt(accessToken);
return {
accessToken,
...(refreshToken ? { refreshToken } : {}),
...(idToken ? { idToken } : {}),
...(expires ? { expires } : {}),
};
}
function deriveExpiresFromJwt(token: string | undefined): number | undefined {
if (!token) {
return undefined;
}
const payload = decodeJwtPayload(token);
const exp = payload.exp;
return resolveExpiresAtMsFromEpochSeconds(exp);
}
function parseXaiOAuthErrorResponse(value: unknown): XaiOAuthErrorResponse {
const json = readStringRecord(value);
const error = typeof json.error === "string" ? json.error : undefined;
const errorDescription =
typeof json.error_description === "string" ? json.error_description : undefined;
return {
...(error ? { error } : {}),
...(errorDescription ? { errorDescription } : {}),
};
}
function formatXaiOAuthError(params: { context: string; status: number; body: unknown }): string {
const error = parseXaiOAuthErrorResponse(params.body);
if (error.error && error.errorDescription) {
return `${params.context} failed (${params.status}): ${error.error} (${error.errorDescription})`;
}
if (error.error) {
return `${params.context} failed (${params.status}): ${error.error}`;
}
return `${params.context} failed (${params.status})`;
}
function isLikelyXaiCloudflareChallenge(params: { response: Response; bodyText: string }): boolean {
const contentType = params.response.headers.get("content-type") ?? "";
return (
params.response.headers.get("cf-mitigated") === "challenge" ||
/text\/html/i.test(contentType) ||
/<!doctype html|<html\b/i.test(params.bodyText) ||
/\b(?:cloudflare|attention required|just a moment|enable javascript and cookies|challenge-platform)\b/i.test(
params.bodyText,
)
);
}
function formatXaiOAuthCloudflareChallengeError(params: {
context: string;
status: number;
}): string {
return (
`${params.context} failed (${params.status}): xAI returned an HTML/Cloudflare challenge ` +
"instead of OAuth JSON. xAI may be blocking the automated token refresh; try again later " +
"or re-run xAI OAuth login."
);
}
/**
* Single source of truth for how a non-OK token response is reported and whether
* it is worth retrying. Detection runs once so the message and the retry decision
* never disagree: a structured OAuth error (e.g. invalid_grant) is authoritative
* and final, while intermediary Cloudflare HTML challenges are retryable.
*/
function describeXaiOAuthTokenFailure(params: {
context: string;
response: Response;
body: XaiOAuthResponseBody;
}): { message: string; retryable: boolean } {
const { context, response, body } = params;
const status = response.status;
const hasStructuredError = Boolean(parseXaiOAuthErrorResponse(body.json).error);
const isCloudflareChallenge =
!hasStructuredError && isLikelyXaiCloudflareChallenge({ response, bodyText: body.text });
return {
message: isCloudflareChallenge
? formatXaiOAuthCloudflareChallengeError({ context, status })
: formatXaiOAuthError({ context, status, body: body.json }),
retryable: isCloudflareChallenge,
};
}
async function exchangeXaiOAuthToken(
params: {
tokenEndpoint: string;
body: Record<string, string>;
context: string;
requireRefreshToken?: boolean;
} & XaiOAuthFetchOptions,
): Promise<XaiOAuthTokenResponse> {
const endpoint = requireTrustedXaiOAuthEndpoint(params.tokenEndpoint, "token endpoint");
const maxAttempts =
params.body.grant_type === "refresh_token" ? XAI_OAUTH_REFRESH_MAX_ATTEMPTS : 1;
let lastMessage = `${params.context} failed`;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
let response: Response;
try {
response = await getFetchImpl(params.fetchImpl)(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": xaiUserAgent(),
},
body: toFormUrlEncoded(params.body),
signal: AbortSignal.timeout(XAI_OAUTH_FETCH_TIMEOUT_MS),
});
} catch (err) {
// Transport failures are not safe to retry for refresh grants: xAI rotates
// refresh tokens, so a response lost after xAI consumed the token would burn
// it on resend. Only Cloudflare challenge responses are retried below.
throw new Error(`${params.context} failed: ${formatErrorMessage(err)}`, { cause: err });
}
const body = await readResponseBody(response);
if (response.ok) {
return parseXaiOAuthTokenResponse(body.json, params.now ?? Date.now, {
requireRefreshToken: params.requireRefreshToken,
});
}
const failure = describeXaiOAuthTokenFailure({ context: params.context, response, body });
lastMessage = failure.message;
if (attempt >= maxAttempts || !failure.retryable) {
throw new Error(lastMessage);
}
await sleep(XAI_OAUTH_REFRESH_RETRY_DELAY_MS);
}
throw new Error(lastMessage);
}
async function requestXaiDeviceCode(
params: {
deviceAuthorizationEndpoint: string;
} & XaiOAuthFetchOptions,
): Promise<XaiDeviceCodeResponse> {
const response = await getFetchImpl(params.fetchImpl)(
requireTrustedXaiOAuthEndpoint(
params.deviceAuthorizationEndpoint,
"device authorization endpoint",
),
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": xaiUserAgent(),
},
body: toFormUrlEncoded({
client_id: XAI_OAUTH_CLIENT_ID,
scope: XAI_OAUTH_SCOPE,
}),
signal: AbortSignal.timeout(XAI_OAUTH_FETCH_TIMEOUT_MS),
},
);
const json = readStringRecord(await readJsonResponse(response, "xAI device code request"));
const deviceCode = json.device_code;
const userCode = json.user_code;
const verificationUri = json.verification_uri;
const verificationUriComplete = json.verification_uri_complete;
if (
typeof deviceCode !== "string" ||
deviceCode.trim().length === 0 ||
typeof userCode !== "string" ||
userCode.trim().length === 0 ||
typeof verificationUri !== "string" ||
verificationUri.trim().length === 0
) {
throw new Error(
"xAI device code response is missing device_code, user_code, or verification_uri",
);
}
const trustedVerificationUri = requireTrustedXaiOAuthEndpoint(
verificationUri,
"device verification URI",
);
const trustedVerificationUriComplete =
typeof verificationUriComplete === "string" && verificationUriComplete.trim().length > 0
? requireTrustedXaiOAuthEndpoint(verificationUriComplete, "complete device verification URI")
: undefined;
return {
deviceCode,
userCode,
verificationUri: trustedVerificationUri,
...(trustedVerificationUriComplete
? { verificationUriComplete: trustedVerificationUriComplete }
: {}),
expiresInMs: positiveSecondsToSafeMilliseconds(json.expires_in) ?? XAI_OAUTH_TIMEOUT_MS,
intervalMs:
positiveSecondsToSafeMilliseconds(json.interval) ?? XAI_DEVICE_CODE_DEFAULT_INTERVAL_MS,
};
}
function resolveNextXaiDeviceCodePollDelayMs(intervalMs: number, deadlineMs: number): number {
const remainingMs = Math.max(0, deadlineMs - Date.now());
return Math.min(Math.max(intervalMs, XAI_DEVICE_CODE_MIN_INTERVAL_MS), remainingMs);
}
async function pollXaiDeviceCodeToken(
params: {
tokenEndpoint: string;
deviceCode: string;
expiresInMs: number;
intervalMs: number;
} & XaiOAuthFetchOptions,
): Promise<XaiOAuthTokenResponse> {
const fetchImpl = getFetchImpl(params.fetchImpl);
const deadlineMs = Date.now() + params.expiresInMs;
let intervalMs = params.intervalMs;
while (Date.now() < deadlineMs) {
const response = await fetchImpl(
requireTrustedXaiOAuthEndpoint(params.tokenEndpoint, "token endpoint"),
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": xaiUserAgent(),
},
body: toFormUrlEncoded({
grant_type: XAI_DEVICE_CODE_GRANT_TYPE,
client_id: XAI_OAUTH_CLIENT_ID,
device_code: params.deviceCode,
}),
signal: AbortSignal.timeout(XAI_OAUTH_FETCH_TIMEOUT_MS),
},
);
let body: unknown;
try {
const buffer = await readResponseWithLimit(response, XAI_OAUTH_RESPONSE_MAX_BYTES, {
onOverflow: ({ maxBytes }) =>
new Error(`xAI device code response exceeds ${maxBytes} bytes`),
});
body = JSON.parse(new TextDecoder().decode(buffer));
} catch {
body = null;
}
if (response.ok) {
return parseXaiOAuthTokenResponse(body, params.now ?? Date.now, {
requireRefreshToken: true,
});
}
const error = parseXaiOAuthErrorResponse(body).error;
if (error === "authorization_pending") {
await new Promise((resolve) => {
setTimeout(resolve, resolveNextXaiDeviceCodePollDelayMs(intervalMs, deadlineMs));
});
continue;
}
if (error === "slow_down") {
intervalMs += XAI_DEVICE_CODE_SLOW_DOWN_INCREMENT_MS;
await new Promise((resolve) => {
setTimeout(resolve, resolveNextXaiDeviceCodePollDelayMs(intervalMs, deadlineMs));
});
continue;
}
if (error === "access_denied" || error === "authorization_denied") {
throw new Error("xAI device authorization was denied");
}
if (error === "expired_token") {
throw new Error("xAI device code expired. Re-run the login.");
}
throw new Error(
formatXaiOAuthError({
context: "xAI device token exchange",
status: response.status,
body,
}),
);
}
throw new Error("xAI device authorization timed out");
}
function decodeJwtPayload(token: string | undefined): Record<string, unknown> {
if (!token) {
return {};
}
const part = token.split(".")[1];
if (!part) {
return {};
}
try {
return readStringRecord(JSON.parse(Buffer.from(part, "base64url").toString("utf8")));
} catch {
return {};
}
}
function resolveXaiOAuthIdentity(tokens: XaiOAuthTokenResponse): XaiOAuthIdentity {
const payload = decodeJwtPayload(tokens.idToken ?? tokens.accessToken);
const email = typeof payload.email === "string" ? payload.email : undefined;
const name = typeof payload.name === "string" ? payload.name : undefined;
const sub = typeof payload.sub === "string" ? payload.sub : undefined;
return {
...(email ? { email } : {}),
...(name ? { displayName: name } : {}),
...(sub ? { accountId: sub } : {}),
};
}
function readCredentialString<TKey extends string>(
credential: OAuthCredential & Partial<Record<TKey, unknown>>,
key: TKey,
): string | undefined {
const value = credential[key];
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function isLegacyXaiOAuthTokenEndpoint(endpoint: string): boolean {
try {
const url = new URL(endpoint);
return `${url.origin}${url.pathname}` === XAI_LEGACY_OAUTH_TOKEN_ENDPOINT;
} catch {
return false;
}
}
async function resolveXaiOAuthRefreshTokenEndpoint(
credential: OAuthCredential,
options: XaiOAuthFetchOptions,
): Promise<string> {
const cachedEndpoint = readCredentialString(credential, "tokenEndpoint");
// Rediscover when there is no cached endpoint, or when an older persisted
// credential still points at the retired endpoint, so refresh writes back the
// current OAuth token endpoint.
if (!cachedEndpoint || isLegacyXaiOAuthTokenEndpoint(cachedEndpoint)) {
return (await fetchXaiOAuthDiscovery(options)).tokenEndpoint;
}
return cachedEndpoint;
}
async function noteXaiDeviceCode(
ctx: ProviderAuthContext,
deviceCode: XaiDeviceCodeResponse,
): Promise<void> {
const expiresInMinutes = Math.max(1, Math.round(deviceCode.expiresInMs / 60_000));
await ctx.prompter.note(
[
ctx.isRemote
? "Open this URL in your LOCAL browser and enter the code below."
: "Open this URL in your browser and enter the code below.",
`URL: ${deviceCode.verificationUriComplete ?? deviceCode.verificationUri}`,
`Code: ${deviceCode.userCode}`,
`Code expires in ${expiresInMinutes} minutes. Never share it.`,
].join("\n"),
"xAI OAuth",
);
}
export async function loginXaiDeviceCode(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {
const progress = ctx.prompter.progress("Starting xAI OAuth...");
try {
const discovery = await fetchXaiDeviceCodeDiscovery();
progress.update("Requesting xAI OAuth device code...");
const deviceCode = await requestXaiDeviceCode({
deviceAuthorizationEndpoint: discovery.deviceAuthorizationEndpoint,
});
await noteXaiDeviceCode(ctx, deviceCode);
const browserUrl = deviceCode.verificationUriComplete ?? deviceCode.verificationUri;
const logUrl = deviceCode.verificationUri;
if (ctx.isRemote) {
ctx.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${logUrl}\n`);
} else {
try {
await ctx.openUrl(browserUrl);
ctx.runtime.log(`Open: ${logUrl}`);
} catch {
ctx.runtime.log(`Open manually: ${logUrl}`);
}
}
progress.update("Waiting for xAI device authorization...");
const tokens = await pollXaiDeviceCodeToken({
tokenEndpoint: discovery.tokenEndpoint,
deviceCode: deviceCode.deviceCode,
expiresInMs: deviceCode.expiresInMs,
intervalMs: deviceCode.intervalMs,
});
const identity = resolveXaiOAuthIdentity(tokens);
progress.stop("xAI OAuth complete");
return buildOauthProviderAuthResult({
providerId: PROVIDER_ID,
defaultModel: XAI_DEFAULT_MODEL_REF,
access: tokens.accessToken,
refresh: tokens.refreshToken,
expires: tokens.expires,
email: identity.email,
displayName: identity.displayName,
profileName: identity.email ?? identity.accountId,
configPatch: applyXaiConfig(ctx.config),
credentialExtra: {
tokenEndpoint: discovery.tokenEndpoint,
deviceAuthorizationEndpoint: discovery.deviceAuthorizationEndpoint,
issuer: XAI_OAUTH_ISSUER,
authFlow: "device-code",
...(tokens.idToken ? { idToken: tokens.idToken } : {}),
...(identity.accountId ? { accountId: identity.accountId } : {}),
},
notes: [
"xAI OAuth uses device-code verification without requiring a localhost callback.",
"xAI may label the consent app as Grok Build because OpenClaw uses xAI's shared OAuth client.",
],
});
} catch (err) {
progress.stop("xAI OAuth failed");
throw new Error(`xAI OAuth failed: ${formatErrorMessage(err)}`, { cause: err });
}
}
export async function refreshXaiOAuthCredential(
credential: OAuthCredential,
options: XaiOAuthFetchOptions = {},
): Promise<OAuthCredential> {
const refreshToken = credential.refresh;
if (!refreshToken) {
throw new Error("xAI OAuth credential is missing refresh token");
}
const tokenEndpoint = await resolveXaiOAuthRefreshTokenEndpoint(credential, options);
const tokens = await exchangeXaiOAuthToken({
...options,
tokenEndpoint,
context: "xAI OAuth refresh",
body: {
grant_type: "refresh_token",
client_id: XAI_OAUTH_CLIENT_ID,
refresh_token: refreshToken,
},
});
const identity = resolveXaiOAuthIdentity(tokens);
return {
...credential,
type: "oauth",
provider: PROVIDER_ID,
access: tokens.accessToken,
refresh: tokens.refreshToken ?? refreshToken,
...(tokens.expires ? { expires: tokens.expires } : {}),
...(tokens.idToken ? { idToken: tokens.idToken } : {}),
...(identity.email ? { email: identity.email } : {}),
...(identity.displayName ? { displayName: identity.displayName } : {}),
...(identity.accountId ? { accountId: identity.accountId } : {}),
tokenEndpoint,
issuer: XAI_OAUTH_ISSUER,
} as OAuthCredential;
}
export function createXaiOAuthAuthMethod(): ProviderAuthMethod {
return {
id: XAI_OAUTH_METHOD_ID,
label: "xAI OAuth",
hint: "Remote-friendly browser sign-in without a localhost callback",
kind: "oauth",
wizard: {
choiceId: XAI_OAUTH_CHOICE_ID,
choiceLabel: "xAI OAuth",
choiceHint: "Remote-friendly browser sign-in without a localhost callback",
groupId: PROVIDER_ID,
groupLabel: "xAI (Grok)",
groupHint: "API key or OAuth",
methodId: XAI_OAUTH_METHOD_ID,
},
run: async (ctx) => loginXaiDeviceCode(ctx),
};
}
export function createXaiDeviceCodeAuthMethod(): ProviderAuthMethod {
return {
id: XAI_DEVICE_CODE_METHOD_ID,
label: "xAI device code",
hint: "Deprecated alias for xAI OAuth device-code login",
kind: "device_code",
wizard: {
choiceId: XAI_DEVICE_CODE_CHOICE_ID,
choiceLabel: "xAI device code",
choiceHint: "Compatibility alias for xAI OAuth device-code sign-in",
assistantVisibility: "manual-only",
groupId: PROVIDER_ID,
groupLabel: "xAI (Grok)",
groupHint: "API key or OAuth",
methodId: XAI_DEVICE_CODE_METHOD_ID,
},
run: async (ctx) => loginXaiDeviceCode(ctx),
};
}

View File

@@ -0,0 +1,324 @@
// Xai tests cover xai plugin behavior.
import fs from "node:fs/promises";
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 {
registerProviderPlugin,
requireRegisteredProvider,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import {
expectOpenClawLiveTranscriptMarker,
runRealtimeSttLiveTest,
} from "openclaw/plugin-sdk/provider-test-contracts";
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { isBillingErrorMessage } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
import { XAI_DEFAULT_STT_MODEL } from "./stt.js";
const XAI_API_KEY = process.env.XAI_API_KEY ?? "";
const LIVE_IMAGE_MODEL = process.env.OPENCLAW_LIVE_XAI_IMAGE_MODEL?.trim() || "grok-imagine-image";
const liveEnabled = XAI_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1";
const describeLive = liveEnabled ? describe : describe.skip;
const EMPTY_AUTH_STORE = { version: 1, profiles: {} } as const;
function createLiveConfig(): OpenClawConfig {
const cfg = getRuntimeConfig();
return {
...cfg,
models: {
...cfg.models,
providers: {
...cfg.models?.providers,
xai: {
...cfg.models?.providers?.xai,
apiKey: XAI_API_KEY,
baseUrl: "https://api.x.ai/v1",
},
},
},
} as OpenClawConfig;
}
function createReferencePng(): Buffer {
const width = 96;
const height = 96;
const buf = Buffer.alloc(width * height * 4, 255);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
fillPixel(buf, x, y, width, 230, 244, 255, 255);
}
}
for (let y = 24; y < 72; y += 1) {
for (let x = 24; x < 72; x += 1) {
fillPixel(buf, x, y, width, 255, 153, 51, 255);
}
}
return encodePngRgba(buf, width, height);
}
async function createTempAgentDir(): Promise<string> {
return await fs.mkdtemp(path.join(os.tmpdir(), "xai-plugin-live-"));
}
const registerXaiPlugin = () =>
registerProviderPlugin({
plugin,
id: "xai",
name: "xAI Provider",
});
async function runXaiLiveCase(label: string, run: () => Promise<void>): Promise<void> {
try {
await run();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (isBillingErrorMessage(message)) {
console.warn(`[xai:live] skip ${label}: billing drift: ${message}`);
return;
}
throw error;
}
}
function isRealtimeOpenBillingDrift(error: Error): boolean {
return isBillingErrorMessage(error.message) || error.message.includes("server response: 429");
}
describeLive("xai plugin live", () => {
it("synthesizes TTS through the registered speech provider", async () => {
await runXaiLiveCase("tts", async () => {
const { speechProviders } = await registerXaiPlugin();
const speechProvider = requireRegisteredProvider(speechProviders, "xai");
const cfg = createLiveConfig();
const voices = await speechProvider.listVoices?.({});
expect(voices?.some((voice) => voice.id === "eve")).toBe(true);
const audioFile = await speechProvider.synthesize({
text: "OpenClaw xAI text to speech integration test OK.",
cfg,
providerConfig: {
apiKey: XAI_API_KEY,
baseUrl: "https://api.x.ai/v1",
voiceId: "eve",
},
target: "audio-file",
timeoutMs: 90_000,
});
expect(audioFile.outputFormat).toBe("mp3");
expect(audioFile.fileExtension).toBe(".mp3");
expect(audioFile.voiceCompatible).toBe(false);
expect(audioFile.audioBuffer.byteLength).toBeGreaterThan(512);
const telephony = await speechProvider.synthesizeTelephony?.({
text: "OpenClaw xAI telephony check OK.",
cfg,
providerConfig: {
apiKey: XAI_API_KEY,
baseUrl: "https://api.x.ai/v1",
voiceId: "eve",
},
timeoutMs: 90_000,
});
if (!telephony) {
throw new Error("xAI telephony synthesis did not return audio");
}
expect(telephony.outputFormat).toBe("pcm");
expect(telephony.sampleRate).toBe(24_000);
expect(telephony?.audioBuffer.byteLength).toBeGreaterThan(512);
});
}, 120_000);
it("transcribes audio through the registered media provider", async () => {
await runXaiLiveCase("stt", async () => {
const { mediaProviders, speechProviders } = await registerXaiPlugin();
const mediaProvider = requireRegisteredProvider(mediaProviders, "xai");
const speechProvider = requireRegisteredProvider(speechProviders, "xai");
const cfg = createLiveConfig();
const phrase = "OpenClaw xAI speech to text integration test OK.";
const audioFile = await speechProvider.synthesize({
text: phrase,
cfg,
providerConfig: {
apiKey: XAI_API_KEY,
baseUrl: "https://api.x.ai/v1",
voiceId: "eve",
},
target: "audio-file",
timeoutMs: 90_000,
});
const transcript = await mediaProvider.transcribeAudio?.({
buffer: audioFile.audioBuffer,
fileName: "xai-stt-live.mp3",
mime: "audio/mpeg",
apiKey: XAI_API_KEY,
baseUrl: "https://api.x.ai/v1",
model: XAI_DEFAULT_STT_MODEL,
timeoutMs: 90_000,
});
const normalized = transcript?.text.toLowerCase() ?? "";
expect(transcript?.model).toBe(XAI_DEFAULT_STT_MODEL);
expectOpenClawLiveTranscriptMarker(normalized);
expect(normalized).toContain("speech");
expect(normalized).toContain("text");
expect(normalized).toContain("integration");
});
}, 180_000);
it("opens xAI realtime STT before sending audio", async () => {
await runXaiLiveCase("realtime-open", async () => {
const { realtimeTranscriptionProviders } = await registerXaiPlugin();
const realtimeProvider = requireRegisteredProvider(realtimeTranscriptionProviders, "xai");
const errors: Error[] = [];
const session = realtimeProvider.createSession({
providerConfig: {
apiKey: XAI_API_KEY,
baseUrl: "https://api.x.ai/v1",
sampleRate: 16_000,
encoding: "pcm",
interimResults: true,
endpointingMs: 800,
language: "en",
},
onError: (error) => errors.push(error),
});
try {
try {
await session.connect();
} catch (error) {
const thrown = error instanceof Error ? error : new Error(String(error));
if (isRealtimeOpenBillingDrift(thrown)) {
console.warn(`[xai:live] skip realtime-open: billing drift: ${thrown.message}`);
return;
}
throw error;
}
const billingError = errors.find(isRealtimeOpenBillingDrift);
if (billingError) {
console.warn(`[xai:live] skip realtime-open: billing drift: ${billingError.message}`);
return;
}
expect(errors).toStrictEqual([]);
expect(session.isConnected()).toBe(true);
} finally {
session.close();
}
});
}, 30_000);
it("streams realtime STT through the registered transcription provider", async () => {
await runXaiLiveCase("realtime-stream", async () => {
const { realtimeTranscriptionProviders, speechProviders } = await registerXaiPlugin();
const realtimeProvider = requireRegisteredProvider(realtimeTranscriptionProviders, "xai");
const speechProvider = requireRegisteredProvider(speechProviders, "xai");
const cfg = createLiveConfig();
const phrase = "OpenClaw xAI realtime transcription integration test OK.";
const telephony = await speechProvider.synthesizeTelephony?.({
text: phrase,
cfg,
providerConfig: {
apiKey: XAI_API_KEY,
baseUrl: "https://api.x.ai/v1",
voiceId: "eve",
},
timeoutMs: 90_000,
});
if (!telephony) {
throw new Error("xAI telephony synthesis did not return audio");
}
expect(telephony.outputFormat).toBe("pcm");
expect(telephony.sampleRate).toBe(24_000);
const chunkSize = Math.max(1, Math.floor(telephony.sampleRate * 2 * 0.1));
const { transcripts, partials } = await runRealtimeSttLiveTest({
provider: realtimeProvider,
providerConfig: {
apiKey: XAI_API_KEY,
baseUrl: "https://api.x.ai/v1",
sampleRate: telephony.sampleRate,
encoding: "pcm",
interimResults: true,
endpointingMs: 500,
language: "en",
},
audio: telephony.audioBuffer,
chunkSize,
delayMs: 20,
closeBeforeWait: true,
});
const normalized = transcripts.join(" ").toLowerCase();
expectOpenClawLiveTranscriptMarker(normalized);
expect(normalized).toContain("transcription");
expect(partials.length + transcripts.length).toBeGreaterThan(0);
});
}, 180_000);
it("generates and edits images through the registered image provider", async () => {
await runXaiLiveCase("image", async () => {
const { imageProviders } = await registerXaiPlugin();
const imageProvider = requireRegisteredProvider(imageProviders, "xai");
const cfg = createLiveConfig();
const agentDir = await createTempAgentDir();
try {
const generated = await imageProvider.generateImage({
provider: "xai",
model: LIVE_IMAGE_MODEL,
prompt: "Create a minimal flat orange square centered on a white background.",
cfg,
agentDir,
authStore: EMPTY_AUTH_STORE,
timeoutMs: 180_000,
count: 1,
aspectRatio: "1:1",
resolution: "1K",
});
expect(generated.model).toBe(LIVE_IMAGE_MODEL);
expect(generated.images.length).toBeGreaterThan(0);
expect(generated.images[0]?.mimeType.startsWith("image/")).toBe(true);
expect(generated.images[0]?.buffer.byteLength).toBeGreaterThan(1_000);
const edited = await imageProvider.generateImage({
provider: "xai",
model: LIVE_IMAGE_MODEL,
prompt:
"Render this image as a pencil sketch with detailed shading. Keep the same framing.",
cfg,
agentDir,
authStore: EMPTY_AUTH_STORE,
timeoutMs: 180_000,
count: 1,
resolution: "1K",
inputImages: [
{
buffer: createReferencePng(),
mimeType: "image/png",
fileName: "reference.png",
},
],
});
expect(edited.model).toBe(LIVE_IMAGE_MODEL);
expect(edited.images.length).toBeGreaterThan(0);
expect(edited.images[0]?.mimeType.startsWith("image/")).toBe(true);
expect(edited.images[0]?.buffer.byteLength).toBeGreaterThan(1_000);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
}, 300_000);
});