feat: finish xai provider integration

This commit is contained in:
Peter Steinberger
2026-03-17 21:26:02 -07:00
parent 2b5fa0931d
commit a8907d80dd
50 changed files with 1900 additions and 610 deletions

View File

@@ -1,4 +1,66 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ModelCompatConfig } from "../config/types.models.js";
export const XAI_TOOL_SCHEMA_PROFILE = "xai";
export const HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING = "html-entities";
function extractModelCompat(
modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined,
): ModelCompatConfig | undefined {
if (!modelOrCompat || typeof modelOrCompat !== "object") {
return undefined;
}
if ("compat" in modelOrCompat) {
const compat = (modelOrCompat as { compat?: unknown }).compat;
return compat && typeof compat === "object" ? (compat as ModelCompatConfig) : undefined;
}
return modelOrCompat as ModelCompatConfig;
}
export function applyModelCompatPatch<T extends { compat?: ModelCompatConfig }>(
model: T,
patch: ModelCompatConfig,
): T {
const nextCompat = { ...model.compat, ...patch };
if (
model.compat &&
Object.entries(patch).every(
([key, value]) => model.compat?.[key as keyof ModelCompatConfig] === value,
)
) {
return model;
}
return {
...model,
compat: nextCompat,
};
}
export function applyXaiModelCompat<T extends { compat?: ModelCompatConfig }>(model: T): T {
return applyModelCompatPatch(model, {
toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE,
nativeWebSearchTool: true,
toolCallArgumentsEncoding: HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING,
});
}
export function usesXaiToolSchemaProfile(
modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined,
): boolean {
return extractModelCompat(modelOrCompat)?.toolSchemaProfile === XAI_TOOL_SCHEMA_PROFILE;
}
export function hasNativeWebSearchTool(
modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined,
): boolean {
return extractModelCompat(modelOrCompat)?.nativeWebSearchTool === true;
}
export function resolveToolCallArgumentsEncoding(
modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined,
): ModelCompatConfig["toolCallArgumentsEncoding"] | undefined {
return extractModelCompat(modelOrCompat)?.toolCallArgumentsEncoding;
}
function isOpenAiCompletionsModel(model: Model<Api>): model is Model<"openai-completions"> {
return model.api === "openai-completions";

View File

@@ -587,6 +587,7 @@ export async function compactEmbeddedPiSessionDirect(
abortSignal: runAbortController.signal,
modelProvider: model.provider,
modelId,
modelCompat: effectiveModel.compat,
modelContextWindowTokens: ctxInfo.tokens,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
});

View File

@@ -0,0 +1,40 @@
import type { Model } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { runExtraParamsCase } from "./extra-params.test-support.js";
vi.mock("@mariozechner/pi-ai", () => ({
streamSimple: vi.fn(() => ({
push: vi.fn(),
result: vi.fn(),
})),
}));
describe("extra-params: Google thinking payload compatibility", () => {
it("strips negative thinking budgets and fills Gemini 3.1 thinkingLevel", () => {
const payload = runExtraParamsCase({
applyProvider: "google",
applyModelId: "gemini-3.1-pro-preview",
model: {
api: "google-generative-ai",
provider: "google",
id: "gemini-3.1-pro-preview",
} as Model<"openai-completions">,
thinkingLevel: "high",
payload: {
contents: [],
config: {
thinkingConfig: {
thinkingBudget: -1,
},
},
},
}).payload as {
config?: {
thinkingConfig?: Record<string, unknown>;
};
};
expect(payload.config?.thinkingConfig?.thinkingBudget).toBeUndefined();
expect(payload.config?.thinkingConfig?.thinkingLevel).toBe("HIGH");
});
});

View File

@@ -11,8 +11,6 @@ import {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,
createAnthropicToolPayloadCompatibilityWrapper,
createBedrockNoCacheWrapper,
isAnthropicBedrockModel,
resolveAnthropicFastMode,
resolveAnthropicBetas,
resolveCacheRetention,
@@ -26,15 +24,12 @@ import {
shouldApplySiliconFlowThinkingOffCompat,
} from "./moonshot-stream-wrappers.js";
import {
createOpenAIAttributionHeadersWrapper,
createOpenAIDefaultTransportWrapper,
createOpenAIFastModeWrapper,
createOpenAIResponsesContextManagementWrapper,
createOpenAIServiceTierWrapper,
resolveOpenAIFastMode,
resolveOpenAIServiceTier,
} from "./openai-stream-wrappers.js";
import { createZaiToolStreamWrapper } from "./zai-stream-wrappers.js";
/**
* Resolve provider-specific extra params from model config.
@@ -127,95 +122,6 @@ function createStreamFnWithExtraParams(
return wrappedStreamFn;
}
function isGemini31Model(modelId: string): boolean {
const normalized = modelId.toLowerCase();
return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash");
}
function mapThinkLevelToGoogleThinkingLevel(
thinkingLevel: ThinkLevel,
): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" | undefined {
switch (thinkingLevel) {
case "minimal":
return "MINIMAL";
case "low":
return "LOW";
case "medium":
case "adaptive":
return "MEDIUM";
case "high":
case "xhigh":
return "HIGH";
default:
return undefined;
}
}
function sanitizeGoogleThinkingPayload(params: {
payload: unknown;
modelId?: string;
thinkingLevel?: ThinkLevel;
}): void {
if (!params.payload || typeof params.payload !== "object") {
return;
}
const payloadObj = params.payload as Record<string, unknown>;
const config = payloadObj.config;
if (!config || typeof config !== "object") {
return;
}
const configObj = config as Record<string, unknown>;
const thinkingConfig = configObj.thinkingConfig;
if (!thinkingConfig || typeof thinkingConfig !== "object") {
return;
}
const thinkingConfigObj = thinkingConfig as Record<string, unknown>;
const thinkingBudget = thinkingConfigObj.thinkingBudget;
if (typeof thinkingBudget !== "number" || thinkingBudget >= 0) {
return;
}
// pi-ai can emit thinkingBudget=-1 for some Gemini 3.1 IDs; a negative budget
// is invalid for Google-compatible backends and can lead to malformed handling.
delete thinkingConfigObj.thinkingBudget;
if (
typeof params.modelId === "string" &&
isGemini31Model(params.modelId) &&
params.thinkingLevel &&
params.thinkingLevel !== "off" &&
thinkingConfigObj.thinkingLevel === undefined
) {
const mappedLevel = mapThinkLevelToGoogleThinkingLevel(params.thinkingLevel);
if (mappedLevel) {
thinkingConfigObj.thinkingLevel = mappedLevel;
}
}
}
function createGoogleThinkingPayloadWrapper(
baseStreamFn: StreamFn | undefined,
thinkingLevel?: ThinkLevel,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const onPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (model.api === "google-generative-ai") {
sanitizeGoogleThinkingPayload({
payload,
modelId: model.id,
thinkingLevel,
});
}
return onPayload?.(payload, model);
},
});
};
}
function resolveAliasedParamValue(
sources: Array<Record<string, unknown> | undefined>,
snakeCaseKey: string,
@@ -305,13 +211,6 @@ export function applyExtraParamsToAgent(
},
}) ?? merged;
if (provider === "openai" || provider === "openai-codex") {
if (provider === "openai") {
// Default OpenAI Responses to WebSocket-first with transparent SSE fallback.
agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn);
}
agent.streamFn = createOpenAIAttributionHeadersWrapper(agent.streamFn);
}
const wrappedStreamFn = createStreamFnWithExtraParams(
agent.streamFn,
effectiveExtraParams,
@@ -370,25 +269,6 @@ export function applyExtraParamsToAgent(
agent.streamFn = createMoonshotThinkingWrapper(agent.streamFn, thinkingType);
}
if (provider === "amazon-bedrock" && !isAnthropicBedrockModel(modelId)) {
log.debug(`disabling prompt caching for non-Anthropic Bedrock model ${provider}/${modelId}`);
agent.streamFn = createBedrockNoCacheWrapper(agent.streamFn);
}
// Enable Z.AI tool_stream for real-time tool call streaming.
// Enabled by default for Z.AI provider, can be disabled via params.tool_stream: false
if (provider === "zai" || provider === "z-ai") {
const toolStreamEnabled = effectiveExtraParams?.tool_stream !== false;
if (toolStreamEnabled) {
log.debug(`enabling Z.AI tool_stream for ${provider}/${modelId}`);
agent.streamFn = createZaiToolStreamWrapper(agent.streamFn, true);
}
}
// Guard Google payloads against invalid negative thinking budgets emitted by
// upstream model-ID heuristics for Gemini 3.1 variants.
agent.streamFn = createGoogleThinkingPayloadWrapper(agent.streamFn, thinkingLevel);
const anthropicFastMode = resolveAnthropicFastMode(effectiveExtraParams);
if (anthropicFastMode !== undefined) {
log.debug(`applying Anthropic fast mode=${anthropicFastMode} for ${provider}/${modelId}`);

View File

@@ -0,0 +1,74 @@
import type { Model } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { runExtraParamsCase } from "./extra-params.test-support.js";
vi.mock("@mariozechner/pi-ai", () => ({
streamSimple: vi.fn(() => ({
push: vi.fn(),
result: vi.fn(),
})),
}));
describe("extra-params: xAI tool payload compatibility", () => {
it("strips function.strict for xai providers", () => {
const payload = runExtraParamsCase({
applyProvider: "xai",
applyModelId: "grok-4-1-fast-reasoning",
model: {
api: "openai-completions",
provider: "xai",
id: "grok-4-1-fast-reasoning",
} as Model<"openai-completions">,
payload: {
model: "grok-4-1-fast-reasoning",
messages: [],
tools: [
{
type: "function",
function: {
name: "write",
description: "write a file",
parameters: { type: "object", properties: {} },
strict: true,
},
},
],
},
}).payload as {
tools?: Array<{ function?: Record<string, unknown> }>;
};
expect(payload.tools?.[0]?.function).not.toHaveProperty("strict");
});
it("keeps function.strict for non-xai providers", () => {
const payload = runExtraParamsCase({
applyProvider: "openai",
applyModelId: "gpt-5.4",
model: {
api: "openai-completions",
provider: "openai",
id: "gpt-5.4",
} as Model<"openai-completions">,
payload: {
model: "gpt-5.4",
messages: [],
tools: [
{
type: "function",
function: {
name: "write",
description: "write a file",
parameters: { type: "object", properties: {} },
strict: true,
},
},
],
},
}).payload as {
tools?: Array<{ function?: Record<string, unknown> }>;
};
expect(payload.tools?.[0]?.function?.strict).toBe(true);
});
});

View File

@@ -30,7 +30,7 @@ function runToolStreamCase(params: ToolStreamCase) {
}).payload as Record<string, unknown>;
}
describe("extra-params: Z.AI tool_stream support", () => {
describe("extra-params: provider tool_stream support", () => {
it("injects tool_stream=true for zai provider by default", () => {
const payload = runToolStreamCase({
applyProvider: "zai",
@@ -45,7 +45,21 @@ describe("extra-params: Z.AI tool_stream support", () => {
expect(payload.tool_stream).toBe(true);
});
it("does not inject tool_stream for non-zai providers", () => {
it("injects tool_stream=true for xai provider by default", () => {
const payload = runToolStreamCase({
applyProvider: "xai",
applyModelId: "grok-4-1-fast-reasoning",
model: {
api: "openai-completions",
provider: "xai",
id: "grok-4-1-fast-reasoning",
} as Model<"openai-completions">,
});
expect(payload.tool_stream).toBe(true);
});
it("does not inject tool_stream for providers that do not need it", () => {
const payload = runToolStreamCase({
applyProvider: "openai",
applyModelId: "gpt-5",
@@ -59,7 +73,7 @@ describe("extra-params: Z.AI tool_stream support", () => {
expect(payload).not.toHaveProperty("tool_stream");
});
it("allows disabling tool_stream via params", () => {
it("allows disabling zai tool_stream via params", () => {
const payload = runToolStreamCase({
applyProvider: "zai",
applyModelId: "glm-5",
@@ -85,4 +99,31 @@ describe("extra-params: Z.AI tool_stream support", () => {
expect(payload).not.toHaveProperty("tool_stream");
});
it("allows disabling xai tool_stream via params", () => {
const payload = runToolStreamCase({
applyProvider: "xai",
applyModelId: "grok-4-1-fast-reasoning",
model: {
api: "openai-completions",
provider: "xai",
id: "grok-4-1-fast-reasoning",
} as Model<"openai-completions">,
cfg: {
agents: {
defaults: {
models: {
"xai/grok-4-1-fast-reasoning": {
params: {
tool_stream: false,
},
},
},
},
},
},
});
expect(payload).not.toHaveProperty("tool_stream");
});
});

View File

@@ -0,0 +1,92 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
function isGemini31Model(modelId: string): boolean {
const normalized = modelId.toLowerCase();
return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash");
}
function mapThinkLevelToGoogleThinkingLevel(
thinkingLevel: ThinkLevel,
): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" | undefined {
switch (thinkingLevel) {
case "minimal":
return "MINIMAL";
case "low":
return "LOW";
case "medium":
case "adaptive":
return "MEDIUM";
case "high":
case "xhigh":
return "HIGH";
default:
return undefined;
}
}
export function sanitizeGoogleThinkingPayload(params: {
payload: unknown;
modelId?: string;
thinkingLevel?: ThinkLevel;
}): void {
if (!params.payload || typeof params.payload !== "object") {
return;
}
const payloadObj = params.payload as Record<string, unknown>;
const config = payloadObj.config;
if (!config || typeof config !== "object") {
return;
}
const configObj = config as Record<string, unknown>;
const thinkingConfig = configObj.thinkingConfig;
if (!thinkingConfig || typeof thinkingConfig !== "object") {
return;
}
const thinkingConfigObj = thinkingConfig as Record<string, unknown>;
const thinkingBudget = thinkingConfigObj.thinkingBudget;
if (typeof thinkingBudget !== "number" || thinkingBudget >= 0) {
return;
}
// pi-ai can emit thinkingBudget=-1 for some Gemini 3.1 IDs; a negative budget
// is invalid for Google-compatible backends and can lead to malformed handling.
delete thinkingConfigObj.thinkingBudget;
if (
typeof params.modelId === "string" &&
isGemini31Model(params.modelId) &&
params.thinkingLevel &&
params.thinkingLevel !== "off" &&
thinkingConfigObj.thinkingLevel === undefined
) {
const mappedLevel = mapThinkLevelToGoogleThinkingLevel(params.thinkingLevel);
if (mappedLevel) {
thinkingConfigObj.thinkingLevel = mappedLevel;
}
}
}
export function createGoogleThinkingPayloadWrapper(
baseStreamFn: StreamFn | undefined,
thinkingLevel?: ThinkLevel,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const onPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (model.api === "google-generative-ai") {
sanitizeGoogleThinkingPayload({
payload,
modelId: model.id,
thinkingLevel,
});
}
return onPayload?.(payload, model);
},
});
};
}

View File

@@ -133,6 +133,29 @@ describe("pi embedded model e2e smoke", () => {
});
});
it("builds an xai forward-compat fallback for Grok 4.1 fast reasoning", () => {
const result = resolveModel("xai", "grok-4-1-fast-reasoning", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "xai",
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
id: "grok-4-1-fast-reasoning",
reasoning: true,
contextWindow: 2_000_000,
});
});
it("keeps unknown-model errors for xai multi-agent-only ids", () => {
const result = resolveModel(
"xai",
"grok-4.20-multi-agent-experimental-beta-0304",
"/tmp/agent",
);
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: xai/grok-4.20-multi-agent-experimental-beta-0304");
});
it("keeps unknown-model errors for unrecognized google-gemini-cli model IDs", () => {
const result = resolveModel("google-gemini-cli", "gemini-4-unknown", "/tmp/agent");
expect(result.model).toBeUndefined();

View File

@@ -55,6 +55,7 @@ import { resolveOpenClawDocsPath } from "../../docs-path.js";
import { isTimeoutError } from "../../failover-error.js";
import { resolveImageSanitizationLimits } from "../../image-sanitization.js";
import { resolveModelAuthMode } from "../../model-auth.js";
import { resolveToolCallArgumentsEncoding } from "../../model-compat.js";
import { normalizeProviderId, resolveDefaultModelForAgent } from "../../model-selection.js";
import { supportsModelTools } from "../../model-tool-support.js";
import { createConfiguredOllamaStreamFn } from "../../ollama-stream.js";
@@ -77,7 +78,6 @@ import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js";
import { createOpenClawCodingTools, resolveToolLoopDetectionConfig } from "../../pi-tools.js";
import { resolveSandboxContext } from "../../sandbox.js";
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
import { isXaiProvider } from "../../schema/clean-for-xai.js";
import { repairSessionFileIfNeeded } from "../../session-file-repair.js";
import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js";
@@ -1534,6 +1534,7 @@ export async function runEmbeddedAttempt(
abortSignal: runAbortController.signal,
modelProvider: params.model.provider,
modelId: params.modelId,
modelCompat: params.model.compat,
modelContextWindowTokens: params.model.contextWindow,
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
currentChannelId: params.currentChannelId,
@@ -2100,7 +2101,7 @@ export async function runEmbeddedAttempt(
);
}
if (isXaiProvider(params.provider, params.modelId)) {
if (resolveToolCallArgumentsEncoding(params.model) === "html-entities") {
activeSession.agent.streamFn = wrapStreamFnDecodeXaiToolCallArguments(
activeSession.agent.streamFn,
);

View File

@@ -2,10 +2,10 @@ import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
/**
* Inject `tool_stream=true` for Z.AI requests so tool-call deltas stream in
* real time. Providers can disable this by setting `params.tool_stream=false`.
* Inject `tool_stream=true` so tool-call deltas stream in real time.
* Providers can disable this by setting `params.tool_stream=false`.
*/
export function createZaiToolStreamWrapper(
export function createToolStreamWrapper(
baseStreamFn: StreamFn | undefined,
enabled: boolean,
): StreamFn {
@@ -27,3 +27,5 @@ export function createZaiToolStreamWrapper(
});
};
}
export const createZaiToolStreamWrapper = createToolStreamWrapper;

View File

@@ -5,6 +5,7 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { applyXaiModelCompat } from "./model-compat.js";
import { createOpenClawTools } from "./openclaw-tools.js";
import { findUnsupportedSchemaKeywords } from "./pi-embedded-runner/google.js";
import { __testing, createOpenClawCodingTools } from "./pi-tools.js";
@@ -453,6 +454,19 @@ describe("createOpenClawCodingTools", () => {
expect(violations).toEqual([]);
}
});
it("applies xai model compat for direct Grok tool cleanup", () => {
const xaiTools = createOpenClawCodingTools({
modelProvider: "xai",
modelCompat: applyXaiModelCompat({}).compat,
senderIsOwner: true,
});
expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(false);
for (const tool of xaiTools) {
const violations = findUnsupportedSchemaKeywords(tool.parameters, `${tool.name}.parameters`);
expect(violations).toEqual([]);
}
});
it("applies sandbox path guards to file_path alias", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-"));
const outsidePath = path.join(os.tmpdir(), "openclaw-outside.txt");

View File

@@ -1,4 +1,8 @@
import { describe, expect, it } from "vitest";
import {
HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING,
XAI_TOOL_SCHEMA_PROFILE,
} from "./model-compat.js";
import { __testing } from "./pi-tools.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
@@ -24,17 +28,22 @@ describe("applyModelProviderToolPolicy", () => {
it("removes web_search for OpenRouter xAI model ids", () => {
const filtered = __testing.applyModelProviderToolPolicy(baseTools, {
modelProvider: "openrouter",
modelId: "x-ai/grok-4.1-fast",
modelCompat: {
toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE,
nativeWebSearchTool: true,
toolCallArgumentsEncoding: HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING,
},
});
expect(toolNames(filtered)).toEqual(["read", "exec"]);
});
it("removes web_search for direct xAI providers", () => {
it("removes web_search for direct xai-capable models too", () => {
const filtered = __testing.applyModelProviderToolPolicy(baseTools, {
modelProvider: "x-ai",
modelId: "grok-4.1",
modelCompat: {
toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE,
nativeWebSearchTool: true,
},
});
expect(toolNames(filtered)).toEqual(["read", "exec"]);

View File

@@ -1,6 +1,8 @@
import type { ModelCompatConfig } from "../config/types.models.js";
import { usesXaiToolSchemaProfile } from "./model-compat.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
import { isXaiProvider, stripXaiUnsupportedKeywords } from "./schema/clean-for-xai.js";
import { stripXaiUnsupportedKeywords } from "./schema/clean-for-xai.js";
function extractEnumValues(schema: unknown): unknown[] | undefined {
if (!schema || typeof schema !== "object") {
@@ -65,7 +67,7 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
export function normalizeToolParameters(
tool: AnyAgentTool,
options?: { modelProvider?: string; modelId?: string },
options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig },
): AnyAgentTool {
const schema =
tool.parameters && typeof tool.parameters === "object"
@@ -88,13 +90,13 @@ export function normalizeToolParameters(
options?.modelProvider?.toLowerCase().includes("google") ||
options?.modelProvider?.toLowerCase().includes("gemini");
const isAnthropicProvider = options?.modelProvider?.toLowerCase().includes("anthropic");
const isXai = isXaiProvider(options?.modelProvider, options?.modelId);
const hasXaiSchemaProfile = usesXaiToolSchemaProfile(options?.modelCompat);
function applyProviderCleaning(s: unknown): unknown {
if (isGeminiProvider && !isAnthropicProvider) {
return cleanSchemaForGemini(s);
}
if (isXai) {
if (hasXaiSchemaProfile) {
return stripXaiUnsupportedKeywords(s);
}
return s;

View File

@@ -1,5 +1,6 @@
import { codingTools, createReadTool, readTool } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../config/config.js";
import type { ModelCompatConfig } from "../config/types.models.js";
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js";
import { logWarn } from "../logger.js";
@@ -17,6 +18,7 @@ import {
import { listChannelAgentTools } from "./channel-tools.js";
import { resolveImageSanitizationLimits } from "./image-sanitization.js";
import type { ModelAuthMode } from "./model-auth.js";
import { hasNativeWebSearchTool } from "./model-compat.js";
import { createOpenClawTools } from "./openclaw-tools.js";
import { wrapToolWithAbortSignal } from "./pi-tools.abort.js";
import { wrapToolWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js";
@@ -44,7 +46,6 @@ import {
import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import type { SandboxContext } from "./sandbox.js";
import { isXaiProvider } from "./schema/clean-for-xai.js";
import { createToolFsPolicy, resolveToolFsConfig } from "./tool-fs-policy.js";
import {
applyToolPolicyPipeline,
@@ -92,13 +93,13 @@ function applyMessageProviderToolPolicy(
function applyModelProviderToolPolicy(
tools: AnyAgentTool[],
params?: { modelProvider?: string; modelId?: string },
params?: { modelCompat?: ModelCompatConfig },
): AnyAgentTool[] {
if (!isXaiProvider(params?.modelProvider, params?.modelId)) {
if (!hasNativeWebSearchTool(params?.modelCompat)) {
return tools;
}
// xAI/Grok providers expose a native web_search tool; sending OpenClaw's
// web_search alongside it causes duplicate-name request failures.
// Models with a native web_search tool cannot receive OpenClaw's
// web_search at the same time or the request will collide.
return tools.filter((tool) => !TOOL_DENY_FOR_XAI_PROVIDERS.has(tool.name));
}
@@ -232,6 +233,8 @@ export function createOpenClawCodingTools(options?: {
modelId?: string;
/** Model context window in tokens (used to scale read-tool output budget). */
modelContextWindowTokens?: number;
/** Resolved runtime model compatibility hints. */
modelCompat?: ModelCompatConfig;
/**
* Auth mode for the current provider. We only need this for Anthropic OAuth
* tool-name blocking quirks.
@@ -567,8 +570,7 @@ export function createOpenClawCodingTools(options?: {
options?.messageProvider,
);
const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, {
modelProvider: options?.modelProvider,
modelId: options?.modelId,
modelCompat: options?.modelCompat,
});
// Security: treat unknown/undefined as unauthorized (opt-in, not opt-out)
const senderIsOwner = options?.senderIsOwner === true;
@@ -601,6 +603,7 @@ export function createOpenClawCodingTools(options?: {
normalizeToolParameters(tool, {
modelProvider: options?.modelProvider,
modelId: options?.modelId,
modelCompat: options?.modelCompat,
}),
);
const withHooks = normalized.map((tool) =>

View File

@@ -1,47 +1,5 @@
import { describe, expect, it } from "vitest";
import { isXaiProvider, stripXaiUnsupportedKeywords } from "./clean-for-xai.js";
describe("isXaiProvider", () => {
it("matches direct xai provider", () => {
expect(isXaiProvider("xai")).toBe(true);
});
it("matches x-ai provider string", () => {
expect(isXaiProvider("x-ai")).toBe(true);
});
it("matches openrouter with x-ai model id", () => {
expect(isXaiProvider("openrouter", "x-ai/grok-4.1-fast")).toBe(true);
});
it("does not match openrouter with non-xai model id", () => {
expect(isXaiProvider("openrouter", "openai/gpt-4o")).toBe(false);
});
it("does not match openai provider", () => {
expect(isXaiProvider("openai")).toBe(false);
});
it("does not match google provider", () => {
expect(isXaiProvider("google")).toBe(false);
});
it("handles undefined provider", () => {
expect(isXaiProvider(undefined)).toBe(false);
});
it("matches venice provider with grok model id", () => {
expect(isXaiProvider("venice", "grok-4.1-fast")).toBe(true);
});
it("matches venice provider with venice/ prefixed grok model id", () => {
expect(isXaiProvider("venice", "venice/grok-4.1-fast")).toBe(true);
});
it("does not match venice provider with non-grok model id", () => {
expect(isXaiProvider("venice", "llama-3.3-70b")).toBe(false);
});
});
import { stripXaiUnsupportedKeywords } from "./clean-for-xai.js";
describe("stripXaiUnsupportedKeywords", () => {
it("strips minLength and maxLength from string properties", () => {

View File

@@ -1,61 +1,6 @@
// xAI rejects these JSON Schema validation keywords in tool definitions instead of
// ignoring them, causing 502 errors for any request that includes them. Strip them
// before sending to xAI directly, or via OpenRouter when the downstream model is xAI.
export const XAI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
"minLength",
"maxLength",
"minItems",
"maxItems",
"minContains",
"maxContains",
]);
import {
stripXaiUnsupportedKeywords,
XAI_UNSUPPORTED_SCHEMA_KEYWORDS,
} from "../../plugin-sdk/provider-tools.js";
export function stripXaiUnsupportedKeywords(schema: unknown): unknown {
if (!schema || typeof schema !== "object") {
return schema;
}
if (Array.isArray(schema)) {
return schema.map(stripXaiUnsupportedKeywords);
}
const obj = schema as Record<string, unknown>;
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (XAI_UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) {
continue;
}
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
cleaned[key] = Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([k, v]) => [
k,
stripXaiUnsupportedKeywords(v),
]),
);
} else if (key === "items" && value && typeof value === "object") {
cleaned[key] = Array.isArray(value)
? value.map(stripXaiUnsupportedKeywords)
: stripXaiUnsupportedKeywords(value);
} else if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
cleaned[key] = value.map(stripXaiUnsupportedKeywords);
} else {
cleaned[key] = value;
}
}
return cleaned;
}
export function isXaiProvider(modelProvider?: string, modelId?: string): boolean {
const provider = modelProvider?.toLowerCase() ?? "";
if (provider.includes("xai") || provider.includes("x-ai")) {
return true;
}
const lowerModelId = modelId?.toLowerCase() ?? "";
// OpenRouter proxies to xAI when the model id starts with "x-ai/"
if (provider === "openrouter" && lowerModelId.startsWith("x-ai/")) {
return true;
}
// Venice proxies to xAI/Grok models
if (provider === "venice" && lowerModelId.includes("grok")) {
return true;
}
return false;
}
export { stripXaiUnsupportedKeywords, XAI_UNSUPPORTED_SCHEMA_KEYWORDS };

View File

@@ -0,0 +1,26 @@
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
export function resolveWebSearchProviderCredential(params: {
credentialValue: unknown;
path: string;
envVars: string[];
}): string | undefined {
const fromConfigRaw = normalizeResolvedSecretInputString({
value: params.credentialValue,
path: params.path,
});
const fromConfig = normalizeSecretInput(fromConfigRaw);
if (fromConfig) {
return fromConfig;
}
for (const envVar of params.envVars) {
const fromEnv = normalizeSecretInput(process.env[envVar]);
if (fromEnv) {
return fromEnv;
}
}
return undefined;
}

View File

@@ -199,272 +199,119 @@ describe("web_search date normalization", () => {
});
});
describe("web_search grok config resolution", () => {
describe("web_search kimi config resolution", () => {
it("uses config apiKey when provided", () => {
expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key"); // pragma: allowlist secret
expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key");
});
it("returns undefined when no apiKey is available", () => {
withEnv({ XAI_API_KEY: undefined }, () => {
expect(resolveGrokApiKey({})).toBeUndefined();
expect(resolveGrokApiKey(undefined)).toBeUndefined();
it("falls back to env apiKey", () => {
withEnv({ [kimiApiKeyEnv]: "kimi-env-key" }, () => {
expect(resolveKimiApiKey({})).toBe("kimi-env-key");
});
});
it("uses default model when not specified", () => {
expect(resolveGrokModel({})).toBe("grok-4-1-fast");
expect(resolveGrokModel(undefined)).toBe("grok-4-1-fast");
});
it("uses config model when provided", () => {
expect(resolveGrokModel({ model: "grok-3" })).toBe("grok-3");
expect(resolveKimiModel({ model: "moonshot-v1-32k" })).toBe("moonshot-v1-32k");
});
it("defaults inlineCitations to false", () => {
expect(resolveGrokInlineCitations({})).toBe(false);
expect(resolveGrokInlineCitations(undefined)).toBe(false);
});
it("respects inlineCitations config", () => {
expect(resolveGrokInlineCitations({ inlineCitations: true })).toBe(true);
expect(resolveGrokInlineCitations({ inlineCitations: false })).toBe(false);
});
});
describe("web_search grok response parsing", () => {
it("extracts content from Responses API message blocks", () => {
const result = extractGrokContent({
output: [
{
type: "message",
content: [{ type: "output_text", text: "hello from output" }],
},
],
});
expect(result.text).toBe("hello from output");
expect(result.annotationCitations).toEqual([]);
});
it("extracts url_citation annotations from content blocks", () => {
const result = extractGrokContent({
output: [
{
type: "message",
content: [
{
type: "output_text",
text: "hello with citations",
annotations: [
{
type: "url_citation",
url: "https://example.com/a",
start_index: 0,
end_index: 5,
},
{
type: "url_citation",
url: "https://example.com/b",
start_index: 6,
end_index: 10,
},
{
type: "url_citation",
url: "https://example.com/a",
start_index: 11,
end_index: 15,
}, // duplicate
],
},
],
},
],
});
expect(result.text).toBe("hello with citations");
expect(result.annotationCitations).toEqual(["https://example.com/a", "https://example.com/b"]);
});
it("falls back to deprecated output_text", () => {
const result = extractGrokContent({ output_text: "hello from output_text" });
expect(result.text).toBe("hello from output_text");
expect(result.annotationCitations).toEqual([]);
});
it("returns undefined text when no content found", () => {
const result = extractGrokContent({});
expect(result.text).toBeUndefined();
expect(result.annotationCitations).toEqual([]);
});
it("extracts output_text blocks directly in output array (no message wrapper)", () => {
const result = extractGrokContent({
output: [
{ type: "web_search_call" },
{
type: "output_text",
text: "direct output text",
annotations: [
{
type: "url_citation",
url: "https://example.com/direct",
start_index: 0,
end_index: 5,
},
],
},
],
} as Parameters<typeof extractGrokContent>[0]);
expect(result.text).toBe("direct output text");
expect(result.annotationCitations).toEqual(["https://example.com/direct"]);
});
});
describe("web_search kimi config resolution", () => {
it("uses config apiKey when provided", () => {
expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key"); // pragma: allowlist secret
});
it("falls back to KIMI_API_KEY, then MOONSHOT_API_KEY", () => {
const kimiEnvValue = "kimi-env"; // pragma: allowlist secret
const moonshotEnvValue = "moonshot-env"; // pragma: allowlist secret
withEnv({ [kimiApiKeyEnv]: kimiEnvValue, [moonshotApiKeyEnv]: moonshotEnvValue }, () => {
expect(resolveKimiApiKey({})).toBe(kimiEnvValue);
});
withEnv({ [kimiApiKeyEnv]: undefined, [moonshotApiKeyEnv]: moonshotEnvValue }, () => {
expect(resolveKimiApiKey({})).toBe(moonshotEnvValue);
});
});
it("returns undefined when no Kimi key is configured", () => {
withEnv({ KIMI_API_KEY: undefined, MOONSHOT_API_KEY: undefined }, () => {
expect(resolveKimiApiKey({})).toBeUndefined();
expect(resolveKimiApiKey(undefined)).toBeUndefined();
});
});
it("resolves default model and baseUrl", () => {
it("falls back to default model", () => {
expect(resolveKimiModel({})).toBe("moonshot-v1-128k");
});
it("uses config baseUrl when provided", () => {
expect(resolveKimiBaseUrl({ baseUrl: "https://kimi.example/v1" })).toBe(
"https://kimi.example/v1",
);
});
it("falls back to default baseUrl", () => {
expect(resolveKimiBaseUrl({})).toBe("https://api.moonshot.ai/v1");
});
});
describe("extractKimiCitations", () => {
it("collects unique URLs from search_results and tool arguments", () => {
it("extracts citations from search_results", () => {
expect(
extractKimiCitations({
search_results: [{ url: "https://example.com/a" }, { url: "https://example.com/a" }],
choices: [
{
message: {
tool_calls: [
{
function: {
arguments: JSON.stringify({
search_results: [{ url: "https://example.com/b" }],
url: "https://example.com/c",
}),
},
},
],
},
},
search_results: [
{ url: "https://example.com/one" },
{ url: "https://example.com/two" },
],
}).toSorted(),
).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]);
}),
).toEqual(["https://example.com/one", "https://example.com/two"]);
});
});
describe("resolveBraveMode", () => {
it("defaults to 'web' when no config is provided", () => {
expect(resolveBraveMode({})).toBe("web");
describe("web_search brave mode resolution", () => {
it("defaults to web mode", () => {
expect(resolveBraveMode(undefined)).toBe("web");
});
it("defaults to 'web' when mode is undefined", () => {
expect(resolveBraveMode({ mode: undefined })).toBe("web");
});
it("returns 'llm-context' when configured", () => {
it("honors explicit llm-context mode", () => {
expect(resolveBraveMode({ mode: "llm-context" })).toBe("llm-context");
});
it("returns 'web' when mode is explicitly 'web'", () => {
expect(resolveBraveMode({ mode: "web" })).toBe("web");
});
it("falls back to 'web' for unrecognized mode values", () => {
expect(resolveBraveMode({ mode: "invalid" })).toBe("web");
});
});
describe("mapBraveLlmContextResults", () => {
it("maps plain string snippets correctly", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [
{
url: "https://example.com/page",
title: "Example Page",
snippets: ["first snippet", "second snippet"],
},
],
},
});
expect(results).toEqual([
it("maps llm context results", () => {
expect(
mapBraveLlmContextResults({
grounding: {
generic: [{ url: "https://example.com", title: "Example", snippets: ["A", "B"] }],
},
sources: [{ url: "https://example.com", hostname: "example.com", date: "2024-01-01" }],
}),
).toEqual([
{
url: "https://example.com/page",
title: "Example Page",
snippets: ["first snippet", "second snippet"],
siteName: "example.com",
title: "Example",
url: "https://example.com",
description: "A B",
age: "2024-01-01",
},
]);
});
});
it("filters out non-string and empty snippets", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [
describe("web_search grok config resolution", () => {
it("uses config apiKey when provided", () => {
expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key");
});
it("falls back to env apiKey", () => {
withEnv({ XAI_API_KEY: "xai-env-key" }, () => {
expect(resolveGrokApiKey({})).toBe("xai-env-key");
});
});
it("uses config model when provided", () => {
expect(resolveGrokModel({ model: "grok-4-fast" })).toBe("grok-4-fast");
});
it("falls back to default model", () => {
expect(resolveGrokModel({})).toBe("grok-4-1-fast");
});
it("resolves inline citations flag", () => {
expect(resolveGrokInlineCitations({ inlineCitations: true })).toBe(true);
expect(resolveGrokInlineCitations({ inlineCitations: false })).toBe(false);
expect(resolveGrokInlineCitations({})).toBe(false);
});
it("extracts content and annotation citations", () => {
expect(
extractGrokContent({
output: [
{
url: "https://example.com",
title: "Test",
snippets: ["valid", "", null, undefined, 42, { text: "object" }] as string[],
type: "message",
content: [
{
type: "output_text",
text: "Result",
annotations: [{ type: "url_citation", url: "https://example.com" }],
},
],
},
],
},
}),
).toEqual({
text: "Result",
annotationCitations: ["https://example.com"],
});
expect(results[0].snippets).toEqual(["valid"]);
});
it("handles missing snippets array", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [{ url: "https://example.com", title: "No Snippets" } as never],
},
});
expect(results[0].snippets).toEqual([]);
});
it("handles empty grounding.generic", () => {
expect(mapBraveLlmContextResults({ grounding: { generic: [] } })).toEqual([]);
});
it("handles missing grounding.generic", () => {
expect(mapBraveLlmContextResults({ grounding: {} } as never)).toEqual([]);
});
it("resolves siteName from URL hostname", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [{ url: "https://docs.example.org/path", title: "Docs", snippets: ["text"] }],
},
});
expect(results[0].siteName).toBe("docs.example.org");
});
it("sets siteName to undefined for invalid URLs", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [{ url: "not-a-url", title: "Bad URL", snippets: ["text"] }],
},
});
expect(results[0].siteName).toBeUndefined();
});
});

View File

@@ -1,123 +1,36 @@
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
import { logVerbose } from "../../globals.js";
import { resolvePluginWebSearchProviders } from "../../plugins/web-search-providers.js";
import type { PluginWebSearchProviderEntry } from "../../plugins/types.js";
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import {
__testing as runtimeTesting,
resolveWebSearchDefinition,
} from "../../web-search/runtime.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult } from "./common.js";
import { SEARCH_CACHE } from "./web-search-provider-common.js";
import {
resolveSearchConfig,
resolveSearchEnabled,
type WebSearchConfig,
} from "./web-search-provider-config.js";
function readProviderEnvValue(envVars: string[]): string | undefined {
for (const envVar of envVars) {
const value = normalizeSecretInput(process.env[envVar]);
if (value) {
return value;
}
}
return undefined;
}
function hasProviderCredential(
provider: PluginWebSearchProviderEntry,
search: WebSearchConfig | undefined,
): boolean {
const rawValue = provider.getCredentialValue(search as Record<string, unknown> | undefined);
const fromConfig = normalizeSecretInput(
normalizeResolvedSecretInputString({
value: rawValue,
path: provider.credentialPath,
}),
);
return Boolean(fromConfig || readProviderEnvValue(provider.envVars));
}
function resolveSearchProvider(search?: WebSearchConfig): string {
const providers = resolvePluginWebSearchProviders({
bundledAllowlistCompat: true,
});
const raw =
search && "provider" in search && typeof search.provider === "string"
? search.provider.trim().toLowerCase()
: "";
if (raw) {
const explicit = providers.find((provider) => provider.id === raw);
if (explicit) {
return explicit.id;
}
}
if (!raw) {
for (const provider of providers) {
if (!hasProviderCredential(provider, search)) {
continue;
}
logVerbose(
`web_search: no provider configured, auto-detected "${provider.id}" from available API keys`,
);
return provider.id;
}
}
return providers[0]?.id ?? "";
}
export function createWebSearchTool(options?: {
config?: OpenClawConfig;
sandboxed?: boolean;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}): AnyAgentTool | null {
const search = resolveSearchConfig(options?.config);
if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) {
return null;
}
const providers = resolvePluginWebSearchProviders({
const resolved = resolveWebSearchDefinition({
config: options?.config,
bundledAllowlistCompat: true,
sandboxed: options?.sandboxed,
runtimeWebSearch: options?.runtimeWebSearch,
});
if (providers.length === 0) {
if (!resolved) {
return null;
}
const providerId =
options?.runtimeWebSearch?.selectedProvider ??
options?.runtimeWebSearch?.providerConfigured ??
resolveSearchProvider(search);
const provider =
providers.find((entry) => entry.id === providerId) ??
providers.find((entry) => entry.id === resolveSearchProvider(search)) ??
providers[0];
if (!provider) {
return null;
}
const definition = provider.createTool({
config: options?.config,
searchConfig: search as Record<string, unknown> | undefined,
runtimeMetadata: options?.runtimeWebSearch,
});
if (!definition) {
return null;
}
return {
label: "Web Search",
name: "web_search",
description: definition.description,
parameters: definition.parameters,
execute: async (_toolCallId, args) => jsonResult(await definition.execute(args)),
description: resolved.definition.description,
parameters: resolved.definition.parameters,
execute: async (_toolCallId, args) => jsonResult(await resolved.definition.execute(args)),
};
}
export const __testing = {
SEARCH_CACHE,
resolveSearchProvider,
...runtimeTesting,
};

169
src/agents/xai.live.test.ts Normal file
View File

@@ -0,0 +1,169 @@
import { completeSimple, getModel, streamSimple } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
import {
createSingleUserPromptMessage,
extractNonEmptyAssistantText,
} from "./live-test-helpers.js";
import { applyExtraParamsToAgent } from "./pi-embedded-runner.js";
import { createWebSearchTool } from "./tools/web-search.js";
const XAI_KEY = process.env.XAI_API_KEY ?? "";
const LIVE = isTruthyEnvValue(process.env.XAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const describeLive = LIVE && XAI_KEY ? describe : describe.skip;
type AssistantLikeMessage = {
content: Array<{
type?: string;
text?: string;
id?: string;
function?: {
strict?: unknown;
};
}>;
};
function resolveLiveXaiModel() {
return getModel("xai", "grok-4-1-fast-reasoning") ?? getModel("xai", "grok-4");
}
async function collectDoneMessage(
stream: AsyncIterable<{ type: string; message?: AssistantLikeMessage }>,
): Promise<AssistantLikeMessage> {
let doneMessage: AssistantLikeMessage | undefined;
for await (const event of stream) {
if (event.type === "done") {
doneMessage = event.message;
}
}
expect(doneMessage).toBeDefined();
return doneMessage!;
}
function extractFirstToolCallId(message: AssistantLikeMessage): string | undefined {
const toolCall = message.content.find((block) => block.type === "toolCall");
return toolCall?.id;
}
describeLive("xai live", () => {
it("returns assistant text for Grok 4.1 Fast Reasoning", async () => {
const model = resolveLiveXaiModel();
expect(model).toBeDefined();
const res = await completeSimple(
model,
{
messages: createSingleUserPromptMessage(),
},
{
apiKey: XAI_KEY,
maxTokens: 64,
reasoning: "medium",
},
);
expect(extractNonEmptyAssistantText(res.content).length).toBeGreaterThan(0);
}, 30_000);
it("applies xAI tool wrappers on live tool calls", async () => {
const model = resolveLiveXaiModel();
expect(model).toBeDefined();
const agent = { streamFn: streamSimple };
applyExtraParamsToAgent(agent, undefined, "xai", model.id);
const noopTool = {
name: "noop",
description: "Return ok.",
parameters: Type.Object({}, { additionalProperties: false }),
};
const prompts = [
"Call the tool `noop` with {}. Do not write any other text.",
"IMPORTANT: Call the tool `noop` with {} and respond only with the tool call.",
"Return only a tool call for `noop` with {}.",
];
let doneMessage: AssistantLikeMessage | undefined;
let capturedPayload: Record<string, unknown> | undefined;
for (const prompt of prompts) {
capturedPayload = undefined;
const stream = agent.streamFn(
model,
{
messages: createSingleUserPromptMessage(prompt),
tools: [noopTool],
},
{
apiKey: XAI_KEY,
maxTokens: 128,
reasoning: "medium",
onPayload: (payload) => {
capturedPayload = payload as Record<string, unknown>;
},
},
);
doneMessage = await collectDoneMessage(
stream as AsyncIterable<{ type: string; message?: AssistantLikeMessage }>,
);
if (extractFirstToolCallId(doneMessage)) {
break;
}
}
expect(doneMessage).toBeDefined();
expect(extractFirstToolCallId(doneMessage!)).toBeDefined();
expect(capturedPayload?.tool_stream).toBe(true);
const payloadTools = Array.isArray(capturedPayload?.tools)
? (capturedPayload.tools as Array<Record<string, unknown>>)
: [];
const firstFunction = payloadTools[0]?.function;
if (firstFunction && typeof firstFunction === "object") {
expect((firstFunction as Record<string, unknown>).strict).toBeUndefined();
}
}, 45_000);
it("runs Grok web_search live", async () => {
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "grok",
grok: {
model: "grok-4-1-fast",
},
},
},
},
},
});
expect(tool).toBeTruthy();
const result = await tool!.execute("web-search:grok-live", {
query: "OpenClaw GitHub",
count: 3,
});
const details = (result.details ?? {}) as {
provider?: string;
content?: string;
citations?: string[];
inlineCitations?: Array<unknown>;
error?: string;
message?: string;
};
expect(details.error, details.message).toBeUndefined();
expect(details.provider).toBe("grok");
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);
}, 45_000);
});

View File

@@ -605,7 +605,14 @@ describe("applyXaiProviderConfig", () => {
expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1");
expect(cfg.models?.providers?.xai?.api).toBe("openai-completions");
expect(cfg.models?.providers?.xai?.apiKey).toBe("old-key");
expect(cfg.models?.providers?.xai?.models.map((m) => m.id)).toEqual(["custom-model", "grok-4"]);
expect(cfg.models?.providers?.xai?.models.map((m) => m.id)).toEqual(
expect.arrayContaining([
"custom-model",
"grok-4",
"grok-4-1-fast-reasoning",
"grok-code-fast-1",
]),
);
});
});

View File

@@ -20,6 +20,9 @@ export type ModelCompatConfig = {
supportsUsageInStreaming?: boolean;
supportsTools?: boolean;
supportsStrictMode?: boolean;
toolSchemaProfile?: "xai";
nativeWebSearchTool?: boolean;
toolCallArgumentsEncoding?: "html-entities";
maxTokensField?: "max_completion_tokens" | "max_tokens";
thinkingFormat?: "openai" | "zai" | "qwen";
requiresToolResultName?: boolean;

View File

@@ -14,7 +14,15 @@ export type { ModelDefinitionConfig } from "../config/types.models.js";
export type { ProviderPlugin } from "../plugins/types.js";
export { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
export { normalizeModelCompat } from "../agents/model-compat.js";
export {
applyXaiModelCompat,
hasNativeWebSearchTool,
HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING,
normalizeModelCompat,
resolveToolCallArgumentsEncoding,
usesXaiToolSchemaProfile,
XAI_TOOL_SCHEMA_PROFILE,
} from "../agents/model-compat.js";
export { normalizeProviderId } from "../agents/provider-id.js";
export { cloneFirstTemplateModel } from "../plugins/provider-model-helpers.js";

View File

@@ -1,5 +1,13 @@
// Public stream-wrapper helpers for provider plugins.
export {
createBedrockNoCacheWrapper,
isAnthropicBedrockModel,
} from "../agents/pi-embedded-runner/anthropic-stream-wrappers.js";
export {
createGoogleThinkingPayloadWrapper,
sanitizeGoogleThinkingPayload,
} from "../agents/pi-embedded-runner/google-stream-wrappers.js";
export {
createKilocodeWrapper,
createOpenRouterSystemCacheWrapper,
@@ -10,7 +18,14 @@ export {
createMoonshotThinkingWrapper,
resolveMoonshotThinkingType,
} from "../agents/pi-embedded-runner/moonshot-stream-wrappers.js";
export { createZaiToolStreamWrapper } from "../agents/pi-embedded-runner/zai-stream-wrappers.js";
export {
createOpenAIAttributionHeadersWrapper,
createOpenAIDefaultTransportWrapper,
} from "../agents/pi-embedded-runner/openai-stream-wrappers.js";
export {
createToolStreamWrapper,
createZaiToolStreamWrapper,
} from "../agents/pi-embedded-runner/zai-stream-wrappers.js";
export {
getOpenRouterModelCapabilities,
loadOpenRouterModelCapabilities,

View File

@@ -0,0 +1,56 @@
// Shared provider-tool helpers for plugin-owned schema compatibility rewrites.
export const XAI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
"minLength",
"maxLength",
"minItems",
"maxItems",
"minContains",
"maxContains",
]);
export function stripUnsupportedSchemaKeywords(
schema: unknown,
unsupportedKeywords: ReadonlySet<string>,
): unknown {
if (!schema || typeof schema !== "object") {
return schema;
}
if (Array.isArray(schema)) {
return schema.map((entry) => stripUnsupportedSchemaKeywords(entry, unsupportedKeywords));
}
const obj = schema as Record<string, unknown>;
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (unsupportedKeywords.has(key)) {
continue;
}
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
cleaned[key] = Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([childKey, childValue]) => [
childKey,
stripUnsupportedSchemaKeywords(childValue, unsupportedKeywords),
]),
);
continue;
}
if (key === "items" && value && typeof value === "object") {
cleaned[key] = Array.isArray(value)
? value.map((entry) => stripUnsupportedSchemaKeywords(entry, unsupportedKeywords))
: stripUnsupportedSchemaKeywords(value, unsupportedKeywords);
continue;
}
if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
cleaned[key] = value.map((entry) =>
stripUnsupportedSchemaKeywords(entry, unsupportedKeywords),
);
continue;
}
cleaned[key] = value;
}
return cleaned;
}
export function stripXaiUnsupportedKeywords(schema: unknown): unknown {
return stripUnsupportedSchemaKeywords(schema, XAI_UNSUPPORTED_SCHEMA_KEYWORDS);
}

View File

@@ -7,15 +7,19 @@ export {
setScopedCredentialValue,
setTopLevelCredentialValue,
} from "../agents/tools/web-search-provider-config.js";
export { resolveWebSearchProviderCredential } from "../agents/tools/web-search-provider-credentials.js";
export { withTrustedWebToolsEndpoint } from "../agents/tools/web-guarded-fetch.js";
export {
DEFAULT_TIMEOUT_SECONDS,
DEFAULT_CACHE_TTL_MINUTES,
normalizeCacheKey,
readCache,
readResponseText,
resolveCacheTtlMs,
resolveTimeoutSeconds,
writeCache,
} from "../agents/tools/web-shared.js";
export { wrapWebContent } from "../security/external-content.js";
/**
* @deprecated Implement provider-owned `createTool(...)` directly on the

View File

@@ -7,6 +7,7 @@ import { resolveBundledPluginsDir } from "./bundled-dir.js";
const tempDirs: string[] = [];
const originalCwd = process.cwd();
const originalBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const originalVitest = process.env.VITEST;
function makeRepoRoot(prefix: string): string {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -21,6 +22,11 @@ afterEach(() => {
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledDir;
}
if (originalVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = originalVitest;
}
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -43,4 +49,23 @@ describe("resolveBundledPluginsDir", () => {
fs.realpathSync(path.join(repoRoot, "dist-runtime", "extensions")),
);
});
it("prefers source extensions under vitest to avoid stale staged plugins", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-dir-vitest-");
fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true });
fs.mkdirSync(path.join(repoRoot, "dist-runtime", "extensions"), { recursive: true });
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
fs.writeFileSync(
path.join(repoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
process.chdir(repoRoot);
process.env.VITEST = "true";
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(repoRoot, "extensions")),
);
});
});

View File

@@ -10,6 +10,8 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env):
return resolveUserPath(override, env);
}
const preferSourceCheckout = Boolean(env.VITEST);
try {
const packageRoots = [
resolveOpenClawPackageRootSync({ cwd: process.cwd() }),
@@ -18,6 +20,10 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env):
(entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index,
);
for (const packageRoot of packageRoots) {
const sourceExtensionsDir = path.join(packageRoot, "extensions");
if (preferSourceCheckout && fs.existsSync(sourceExtensionsDir)) {
return sourceExtensionsDir;
}
// Local source checkouts stage a runtime-complete bundled plugin tree under
// dist-runtime/. Prefer that over source extensions only when the paired
// dist/ tree exists; otherwise wrappers can drift ahead of the last build.

View File

@@ -429,6 +429,120 @@ describe("provider runtime contract", () => {
});
});
describe("xai", () => {
it("owns Grok forward-compat resolution for newer fast models", () => {
const provider = requireProviderContractProvider("xai");
const model = provider.resolveDynamicModel?.({
provider: "xai",
modelId: "grok-4-1-fast-reasoning",
modelRegistry: {
find: () => null,
} as never,
providerConfig: {
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
},
});
expect(model).toMatchObject({
id: "grok-4-1-fast-reasoning",
provider: "xai",
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
reasoning: true,
contextWindow: 2_000_000,
});
});
it("owns xai modern-model matching without accepting multi-agent ids", () => {
const provider = requireProviderContractProvider("xai");
expect(
provider.isModernModelRef?.({
provider: "xai",
modelId: "grok-4-1-fast-reasoning",
} as never),
).toBe(true);
expect(
provider.isModernModelRef?.({
provider: "xai",
modelId: "grok-4.20-multi-agent-experimental-beta-0304",
} as never),
).toBe(false);
});
it("owns direct xai compat flags on resolved models", () => {
const provider = requireProviderContractProvider("xai");
expect(
provider.normalizeResolvedModel?.({
provider: "xai",
modelId: "grok-4-1-fast",
model: createModel({
id: "grok-4-1-fast",
provider: "xai",
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
}),
} as never),
).toMatchObject({
compat: {
toolSchemaProfile: "xai",
nativeWebSearchTool: true,
toolCallArgumentsEncoding: "html-entities",
},
});
});
});
describe("openrouter", () => {
it("owns xai downstream compat flags for x-ai routed models", () => {
const provider = requireProviderContractProvider("openrouter");
expect(
provider.normalizeResolvedModel?.({
provider: "openrouter",
modelId: "x-ai/grok-4-1-fast",
model: createModel({
id: "x-ai/grok-4-1-fast",
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
}),
}),
).toMatchObject({
compat: {
toolSchemaProfile: "xai",
nativeWebSearchTool: true,
toolCallArgumentsEncoding: "html-entities",
},
});
});
});
describe("venice", () => {
it("owns xai downstream compat flags for grok-backed Venice models", () => {
const provider = requireProviderContractProvider("venice");
expect(
provider.normalizeResolvedModel?.({
provider: "venice",
modelId: "grok-41-fast",
model: createModel({
id: "grok-41-fast",
provider: "venice",
api: "openai-completions",
baseUrl: "https://api.venice.ai/api/v1",
}),
}),
).toMatchObject({
compat: {
toolSchemaProfile: "xai",
nativeWebSearchTool: true,
toolCallArgumentsEncoding: "html-entities",
},
});
});
});
describe("openai-codex", () => {
it("owns refresh fallback for accountId extraction failures", async () => {
const provider = requireProviderContractProvider("openai-codex");