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:
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 {};
|
||||
}
|
||||
Reference in New Issue
Block a user