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
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:
3
extensions/xai/.boundary-stubs/anthropic-vertex-api.d.ts
vendored
Normal file
3
extensions/xai/.boundary-stubs/anthropic-vertex-api.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
// Xai type declarations define plugin contracts.
|
||||
export const resolveAnthropicVertexClientRegion: (...args: unknown[]) => unknown;
|
||||
export const resolveAnthropicVertexProjectId: (...args: unknown[]) => unknown;
|
||||
2
extensions/xai/.boundary-stubs/ollama-api.d.ts
vendored
Normal file
2
extensions/xai/.boundary-stubs/ollama-api.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// Xai type declarations define plugin contracts.
|
||||
export const resolveOllamaApiBase: (...args: unknown[]) => unknown;
|
||||
17
extensions/xai/.boundary-stubs/ollama-runtime-api.d.ts
vendored
Normal file
17
extensions/xai/.boundary-stubs/ollama-runtime-api.d.ts
vendored
Normal 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;
|
||||
34
extensions/xai/.boundary-stubs/speech-core-runtime-api.d.ts
vendored
Normal file
34
extensions/xai/.boundary-stubs/speech-core-runtime-api.d.ts
vendored
Normal 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;
|
||||
34
extensions/xai/api.test.ts
Normal file
34
extensions/xai/api.test.ts
Normal 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
107
extensions/xai/api.ts
Normal 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";
|
||||
}
|
||||
30
extensions/xai/code-execution-tool-shared.ts
Normal file
30
extensions/xai/code-execution-tool-shared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
270
extensions/xai/code-execution.test.ts
Normal file
270
extensions/xai/code-execution.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
82
extensions/xai/code-execution.ts
Normal file
82
extensions/xai/code-execution.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
299
extensions/xai/image-generation-provider.test.ts
Normal file
299
extensions/xai/image-generation-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
125
extensions/xai/image-generation-provider.ts
Normal file
125
extensions/xai/image-generation-provider.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
612
extensions/xai/index.test.ts
Normal file
612
extensions/xai/index.test.ts
Normal 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
266
extensions/xai/index.ts
Normal 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" });
|
||||
},
|
||||
});
|
||||
35
extensions/xai/model-compat.ts
Normal file
35
extensions/xai/model-compat.ts
Normal 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;
|
||||
}
|
||||
346
extensions/xai/model-definitions.ts
Normal file
346
extensions/xai/model-definitions.ts
Normal 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;
|
||||
}
|
||||
33
extensions/xai/model-id.test.ts
Normal file
33
extensions/xai/model-id.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
25
extensions/xai/model-id.ts
Normal file
25
extensions/xai/model-id.ts
Normal 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;
|
||||
}
|
||||
92
extensions/xai/onboard.test.ts
Normal file
92
extensions/xai/onboard.test.ts
Normal 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
57
extensions/xai/onboard.ts
Normal 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");
|
||||
}
|
||||
314
extensions/xai/openclaw.plugin.json
Normal file
314
extensions/xai/openclaw.plugin.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
extensions/xai/openclaw.plugin.test.ts
Normal file
34
extensions/xai/openclaw.plugin.test.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
19
extensions/xai/package.json
Normal file
19
extensions/xai/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
212
extensions/xai/provider-catalog.ts
Normal file
212
extensions/xai/provider-catalog.ts
Normal 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();
|
||||
}
|
||||
23
extensions/xai/provider-contract-api.ts
Normal file
23
extensions/xai/provider-contract-api.ts
Normal 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)",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
28
extensions/xai/provider-discovery.ts
Normal file
28
extensions/xai/provider-discovery.ts
Normal 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;
|
||||
46
extensions/xai/provider-models.ts
Normal file
46
extensions/xai/provider-models.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
38
extensions/xai/provider-policy-api.test.ts
Normal file
38
extensions/xai/provider-policy-api.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
19
extensions/xai/provider-policy-api.ts
Normal file
19
extensions/xai/provider-policy-api.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
280
extensions/xai/realtime-transcription-provider.test.ts
Normal file
280
extensions/xai/realtime-transcription-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
283
extensions/xai/realtime-transcription-provider.ts
Normal file
283
extensions/xai/realtime-transcription-provider.ts
Normal 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.",
|
||||
);
|
||||
}
|
||||
61
extensions/xai/runtime-model-compat.test.ts
Normal file
61
extensions/xai/runtime-model-compat.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
75
extensions/xai/runtime-model-compat.ts
Normal file
75
extensions/xai/runtime-model-compat.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
23
extensions/xai/setup-api.ts
Normal file
23
extensions/xai/setup-api.ts
Normal 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;
|
||||
});
|
||||
},
|
||||
});
|
||||
213
extensions/xai/speech-provider.test.ts
Normal file
213
extensions/xai/speech-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
297
extensions/xai/speech-provider.ts
Normal file
297
extensions/xai/speech-provider.ts
Normal 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.",
|
||||
);
|
||||
}
|
||||
56
extensions/xai/src/code-execution-config.ts
Normal file
56
extensions/xai/src/code-execution-config.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
111
extensions/xai/src/code-execution-shared.ts
Normal file
111
extensions/xai/src/code-execution-shared.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
108
extensions/xai/src/responses-tool-shared.test.ts
Normal file
108
extensions/xai/src/responses-tool-shared.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
164
extensions/xai/src/responses-tool-shared.ts
Normal file
164
extensions/xai/src/responses-tool-shared.ts
Normal 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 };
|
||||
328
extensions/xai/src/tool-auth-shared.test.ts
Normal file
328
extensions/xai/src/tool-auth-shared.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
194
extensions/xai/src/tool-auth-shared.ts
Normal file
194
extensions/xai/src/tool-auth-shared.ts
Normal 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]));
|
||||
}
|
||||
37
extensions/xai/src/tool-config-shared.test.ts
Normal file
37
extensions/xai/src/tool-config-shared.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
33
extensions/xai/src/tool-config-shared.ts
Normal file
33
extensions/xai/src/tool-config-shared.ts
Normal 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;
|
||||
}
|
||||
433
extensions/xai/src/web-search-provider.runtime.ts
Normal file
433
extensions/xai/src/web-search-provider.runtime.ts
Normal 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 };
|
||||
26
extensions/xai/src/web-search-response.types.ts
Normal file
26
extensions/xai/src/web-search-response.types.ts
Normal 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;
|
||||
}>;
|
||||
};
|
||||
125
extensions/xai/src/web-search-shared.ts
Normal file
125
extensions/xai/src/web-search-shared.ts
Normal 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));
|
||||
}
|
||||
79
extensions/xai/src/x-search-config.ts
Normal file
79
extensions/xai/src/x-search-config.ts
Normal 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;
|
||||
}
|
||||
147
extensions/xai/src/x-search-shared.ts
Normal file
147
extensions/xai/src/x-search-shared.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
60
extensions/xai/src/xai-user-agent.test.ts
Normal file
60
extensions/xai/src/xai-user-agent.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
52
extensions/xai/src/xai-user-agent.ts
Normal file
52
extensions/xai/src/xai-user-agent.ts
Normal 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 {};
|
||||
}
|
||||
655
extensions/xai/stream.test.ts
Normal file
655
extensions/xai/stream.test.ts
Normal 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;" },
|
||||
};
|
||||
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;");
|
||||
});
|
||||
|
||||
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
314
extensions/xai/stream.ts
Normal 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
107
extensions/xai/stt.test.ts
Normal 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
93
extensions/xai/stt.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2
extensions/xai/test-api.ts
Normal file
2
extensions/xai/test-api.ts
Normal 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";
|
||||
74
extensions/xai/test-helpers.ts
Normal file
74
extensions/xai/test-helpers.ts
Normal 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");
|
||||
}
|
||||
418
extensions/xai/tsconfig.json
Normal file
418
extensions/xai/tsconfig.json
Normal 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
176
extensions/xai/tts.test.ts
Normal 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
105
extensions/xai/tts.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
617
extensions/xai/video-generation-provider.test.ts
Normal file
617
extensions/xai/video-generation-provider.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
510
extensions/xai/video-generation-provider.ts
Normal file
510
extensions/xai/video-generation-provider.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
10
extensions/xai/web-search-contract-api.ts
Normal file
10
extensions/xai/web-search-contract-api.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
32
extensions/xai/web-search-provider-shared.ts
Normal file
32
extensions/xai/web-search-provider-shared.ts
Normal 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" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
1226
extensions/xai/web-search.test.ts
Normal file
1226
extensions/xai/web-search.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
48
extensions/xai/web-search.ts
Normal file
48
extensions/xai/web-search.ts
Normal 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);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
52
extensions/xai/x-search-tool-shared.ts
Normal file
52
extensions/xai/x-search-tool-shared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
77
extensions/xai/x-search.live.test.ts
Normal file
77
extensions/xai/x-search.live.test.ts
Normal 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);
|
||||
});
|
||||
518
extensions/xai/x-search.test.ts
Normal file
518
extensions/xai/x-search.test.ts
Normal 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
231
extensions/xai/x-search.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
574
extensions/xai/xai-oauth.test.ts
Normal file
574
extensions/xai/xai-oauth.test.ts
Normal 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
708
extensions/xai/xai-oauth.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
324
extensions/xai/xai.live.test.ts
Normal file
324
extensions/xai/xai.live.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user