feat: finish xai provider integration

This commit is contained in:
Peter Steinberger 2026-03-17 21:26:02 -07:00
parent 2b5fa0931d
commit a8907d80dd
No known key found for this signature in database
50 changed files with 1900 additions and 610 deletions

View File

@ -47,6 +47,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Venice (Venice AI, privacy-focused)](/providers/venice)
- [vLLM (local models)](/providers/vllm)
- [xAI](/providers/xai)
- [Xiaomi](/providers/xiaomi)
- [Z.AI](/providers/zai)

View File

@ -39,6 +39,7 @@ model as `provider/model`.
- [Venice (Venice AI)](/providers/venice)
- [Amazon Bedrock](/providers/bedrock)
- [Qianfan](/providers/qianfan)
- [xAI](/providers/xai)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).

61
docs/providers/xai.md Normal file
View File

@ -0,0 +1,61 @@
---
summary: "Use xAI Grok models in OpenClaw"
read_when:
- You want to use Grok models in OpenClaw
- You are configuring xAI auth or model ids
title: "xAI"
---
# xAI
OpenClaw ships a bundled `xai` provider plugin for Grok models.
## Setup
1. Create an API key in the xAI console.
2. Set `XAI_API_KEY`, or run:
```bash
openclaw onboard --auth-choice xai-api-key
```
3. Pick a model such as:
```json5
{
agents: { defaults: { model: { primary: "xai/grok-4" } } },
}
```
## Current bundled model catalog
OpenClaw now includes these xAI model families out of the box:
- `grok-4`, `grok-4-0709`
- `grok-4-fast-reasoning`, `grok-4-fast-non-reasoning`
- `grok-4-1-fast-reasoning`, `grok-4-1-fast-non-reasoning`
- `grok-4.20-experimental-beta-0304-reasoning`
- `grok-4.20-experimental-beta-0304-non-reasoning`
- `grok-code-fast-1`
The plugin also forward-resolves newer `grok-4*` and `grok-code-fast*` ids when
they follow the same API shape.
## Web search
The bundled `grok` web-search provider uses `XAI_API_KEY` too:
```bash
openclaw config set tools.web.search.provider grok
```
## Known limits
- Auth is API-key only today. There is no xAI OAuth/device-code flow in OpenClaw yet.
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the normal xAI provider path because it requires a different upstream API surface than the standard OpenClaw xAI transport.
- Native xAI server-side tools such as `x_search` and `code_execution` are not yet first-class model-provider features in the bundled plugin.
## Notes
- OpenClaw applies xAI-specific tool-schema and tool-call compatibility fixes automatically on the shared runner path.
- For the broader provider overview, see [Model providers](/providers/index).

View File

@ -19,4 +19,27 @@ describe("amazon-bedrock provider plugin", () => {
} as never),
).toBeUndefined();
});
it("disables prompt caching for non-Anthropic Bedrock models", () => {
const provider = registerSingleProviderPlugin(amazonBedrockPlugin);
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId: "amazon.nova-micro-v1:0",
streamFn: (_model, _context, options) => options,
} as never);
expect(
wrapped?.(
{
api: "openai-completions",
provider: "amazon-bedrock",
id: "amazon.nova-micro-v1:0",
} as never,
{ messages: [] } as never,
{},
),
).toMatchObject({
cacheRetention: "none",
});
});
});

View File

@ -1,4 +1,8 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import {
createBedrockNoCacheWrapper,
isAnthropicBedrockModel,
} from "openclaw/plugin-sdk/provider-stream";
const PROVIDER_ID = "amazon-bedrock";
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
@ -13,6 +17,8 @@ export default definePluginEntry({
label: "Amazon Bedrock",
docsPath: "/providers/models",
auth: [],
wrapStreamFn: ({ modelId, streamFn }) =>
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn),
resolveDefaultThinkingLevel: ({ modelId }) =>
CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined,
});

View File

@ -5,6 +5,7 @@ import {
GOOGLE_GEMINI_DEFAULT_MODEL,
applyGoogleGeminiModelDefault,
} from "openclaw/plugin-sdk/provider-models";
import { createGoogleThinkingPayloadWrapper } from "openclaw/plugin-sdk/provider-stream";
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
import { googleMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
@ -44,6 +45,7 @@ export default definePluginEntry({
],
resolveDynamicModel: (ctx) =>
resolveGoogle31ForwardCompatModel({ providerId: "google", ctx }),
wrapStreamFn: (ctx) => createGoogleThinkingPayloadWrapper(ctx.streamFn, ctx.thinkingLevel),
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
});
registerGoogleGeminiCliProvider(api);

View File

@ -18,6 +18,7 @@ import {
normalizeProviderId,
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-models";
import { createOpenAIAttributionHeadersWrapper } from "openclaw/plugin-sdk/provider-stream";
import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage";
import { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
import {
@ -248,6 +249,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
transport: "auto",
};
},
wrapStreamFn: (ctx) => createOpenAIAttributionHeadersWrapper(ctx.streamFn),
normalizeResolvedModel: (ctx) => {
if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) {
return undefined;

View File

@ -11,6 +11,10 @@ import {
OPENAI_DEFAULT_MODEL,
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-models";
import {
createOpenAIAttributionHeadersWrapper,
createOpenAIDefaultTransportWrapper,
} from "openclaw/plugin-sdk/provider-stream";
import {
cloneFirstTemplateModel,
findCatalogTemplate,
@ -169,6 +173,8 @@ export function buildOpenAIProvider(): ProviderPlugin {
capabilities: {
providerFamily: "openai",
},
wrapStreamFn: (ctx) =>
createOpenAIAttributionHeadersWrapper(createOpenAIDefaultTransportWrapper(ctx.streamFn)),
supportsXHighThinking: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_XHIGH_MODEL_IDS),
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_MODERN_MODEL_IDS),
buildMissingAuthMessage: (ctx) => {

View File

@ -5,7 +5,7 @@ import {
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
import { DEFAULT_CONTEXT_TOKENS } from "openclaw/plugin-sdk/provider-models";
import { applyXaiModelCompat, DEFAULT_CONTEXT_TOKENS } from "openclaw/plugin-sdk/provider-models";
import {
getOpenRouterModelCapabilities,
loadOpenRouterModelCapabilities,
@ -73,6 +73,10 @@ function isOpenRouterCacheTtlModel(modelId: string): boolean {
return OPENROUTER_CACHE_TTL_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix));
}
function isXaiOpenRouterModel(modelId: string): boolean {
return modelId.trim().toLowerCase().startsWith("x-ai/");
}
export default definePluginEntry({
id: "openrouter",
name: "OpenRouter Provider",
@ -129,6 +133,8 @@ export default definePluginEntry({
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],
},
normalizeResolvedModel: ({ modelId, model }) =>
isXaiOpenRouterModel(modelId) ? applyXaiModelCompat(model) : undefined,
isModernModelRef: () => true,
wrapStreamFn: (ctx) => {
let streamFn = ctx.streamFn;

View File

@ -1,11 +1,16 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog";
import { applyXaiModelCompat } from "openclaw/plugin-sdk/provider-models";
import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildVeniceProvider } from "./provider-catalog.js";
const PROVIDER_ID = "venice";
function isXaiBackedVeniceModel(modelId: string): boolean {
return modelId.trim().toLowerCase().includes("grok");
}
export default definePluginEntry({
id: PROVIDER_ID,
name: "Venice Provider",
@ -53,6 +58,8 @@ export default definePluginEntry({
buildProvider: buildVeniceProvider,
}),
},
normalizeResolvedModel: ({ modelId, model }) =>
isXaiBackedVeniceModel(modelId) ? applyXaiModelCompat(model) : undefined,
});
},
});

View File

@ -1,16 +1,18 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-models";
import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog";
import { applyXaiModelCompat } from "openclaw/plugin-sdk/provider-models";
import { createToolStreamWrapper } from "openclaw/plugin-sdk/provider-stream";
import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js";
import { createGrokWebSearchProvider } from "./src/grok-web-search-provider.js";
import { buildXaiProvider } from "./provider-catalog.js";
import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
import {
createXaiToolCallArgumentDecodingWrapper,
createXaiToolPayloadCompatibilityWrapper,
} from "./stream.js";
import { createXaiWebSearchProvider } from "./web-search.js";
const PROVIDER_ID = "xai";
const XAI_MODERN_MODEL_PREFIXES = ["grok-4"] as const;
function matchesModernXaiModel(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
return XAI_MODERN_MODEL_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
export default definePluginEntry({
id: "xai",
@ -20,7 +22,8 @@ export default definePluginEntry({
api.registerProvider({
id: PROVIDER_ID,
label: "xAI",
docsPath: "/providers/models",
aliases: ["x-ai"],
docsPath: "/providers/xai",
envVars: ["XAI_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
@ -44,9 +47,35 @@ export default definePluginEntry({
},
}),
],
isModernModelRef: ({ provider, modelId }) =>
normalizeProviderId(provider) === "xai" ? matchesModernXaiModel(modelId) : undefined,
catalog: {
order: "simple",
run: (ctx) =>
buildSingleProviderApiKeyCatalog({
ctx,
providerId: PROVIDER_ID,
buildProvider: buildXaiProvider,
}),
},
prepareExtraParams: (ctx) => {
if (ctx.extraParams?.tool_stream !== undefined) {
return ctx.extraParams;
}
return {
...ctx.extraParams,
tool_stream: true,
};
},
wrapStreamFn: (ctx) =>
createToolStreamWrapper(
createXaiToolCallArgumentDecodingWrapper(
createXaiToolPayloadCompatibilityWrapper(ctx.streamFn),
),
ctx.extraParams?.tool_stream !== false,
),
normalizeResolvedModel: ({ model }) => applyXaiModelCompat(model),
resolveDynamicModel: (ctx) => resolveXaiForwardCompatModel({ providerId: PROVIDER_ID, ctx }),
isModernModelRef: ({ modelId }) => isModernXaiModel(modelId),
});
api.registerWebSearchProvider(createGrokWebSearchProvider());
api.registerWebSearchProvider(createXaiWebSearchProvider());
},
});

View File

@ -4,6 +4,8 @@ export const XAI_BASE_URL = "https://api.x.ai/v1";
export const XAI_DEFAULT_MODEL_ID = "grok-4";
export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`;
export const XAI_DEFAULT_CONTEXT_WINDOW = 131072;
export const XAI_LARGE_CONTEXT_WINDOW = 2_000_000;
export const XAI_CODE_CONTEXT_WINDOW = 256_000;
export const XAI_DEFAULT_MAX_TOKENS = 8192;
export const XAI_DEFAULT_COST = {
input: 0,
@ -12,14 +14,133 @@ export const XAI_DEFAULT_COST = {
cacheWrite: 0,
};
export function buildXaiModelDefinition(): ModelDefinitionConfig {
return {
id: XAI_DEFAULT_MODEL_ID,
type XaiCatalogEntry = {
id: string;
name: string;
reasoning: boolean;
contextWindow: number;
};
const XAI_MODEL_CATALOG = [
{
id: "grok-4",
name: "Grok 4",
reasoning: false,
contextWindow: XAI_DEFAULT_CONTEXT_WINDOW,
},
{
id: "grok-4-0709",
name: "Grok 4 0709",
reasoning: false,
contextWindow: XAI_DEFAULT_CONTEXT_WINDOW,
},
{
id: "grok-4-fast-reasoning",
name: "Grok 4 Fast (Reasoning)",
reasoning: true,
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
},
{
id: "grok-4-fast-non-reasoning",
name: "Grok 4 Fast (Non-Reasoning)",
reasoning: false,
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
},
{
id: "grok-4-1-fast-reasoning",
name: "Grok 4.1 Fast (Reasoning)",
reasoning: true,
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
},
{
id: "grok-4-1-fast-non-reasoning",
name: "Grok 4.1 Fast (Non-Reasoning)",
reasoning: false,
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
},
{
id: "grok-4.20-experimental-beta-0304-reasoning",
name: "Grok 4.20 Experimental Beta 0304 (Reasoning)",
reasoning: true,
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
},
{
id: "grok-4.20-experimental-beta-0304-non-reasoning",
name: "Grok 4.20 Experimental Beta 0304 (Non-Reasoning)",
reasoning: false,
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
},
{
id: "grok-code-fast-1",
name: "Grok Code Fast 1",
reasoning: true,
contextWindow: XAI_CODE_CONTEXT_WINDOW,
},
] as const satisfies readonly XaiCatalogEntry[];
function toModelDefinition(entry: XaiCatalogEntry): ModelDefinitionConfig {
return {
id: entry.id,
name: entry.name,
reasoning: entry.reasoning,
input: ["text"],
cost: XAI_DEFAULT_COST,
contextWindow: XAI_DEFAULT_CONTEXT_WINDOW,
contextWindow: entry.contextWindow,
maxTokens: XAI_DEFAULT_MAX_TOKENS,
};
}
export function buildXaiModelDefinition(): ModelDefinitionConfig {
return toModelDefinition(
XAI_MODEL_CATALOG.find((entry) => entry.id === XAI_DEFAULT_MODEL_ID) ?? {
id: XAI_DEFAULT_MODEL_ID,
name: "Grok 4",
reasoning: false,
contextWindow: XAI_DEFAULT_CONTEXT_WINDOW,
},
);
}
export function buildXaiCatalogModels(): ModelDefinitionConfig[] {
return XAI_MODEL_CATALOG.map((entry) => toModelDefinition(entry));
}
export function resolveXaiCatalogEntry(modelId: string): ModelDefinitionConfig | undefined {
const lower = modelId.trim().toLowerCase();
const exact = XAI_MODEL_CATALOG.find((entry) => entry.id.toLowerCase() === lower);
if (exact) {
return toModelDefinition(exact);
}
if (lower.includes("multi-agent")) {
return undefined;
}
if (lower.startsWith("grok-code-fast")) {
return toModelDefinition({
id: modelId.trim(),
name: modelId.trim(),
reasoning: true,
contextWindow: XAI_CODE_CONTEXT_WINDOW,
});
}
if (
lower.startsWith("grok-4.20") ||
lower.startsWith("grok-4-1") ||
lower.startsWith("grok-4-fast")
) {
return toModelDefinition({
id: modelId.trim(),
name: modelId.trim(),
reasoning: !lower.includes("non-reasoning"),
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
});
}
if (lower.startsWith("grok-4")) {
return toModelDefinition({
id: modelId.trim(),
name: modelId.trim(),
reasoning: lower.includes("reasoning"),
contextWindow: XAI_DEFAULT_CONTEXT_WINDOW,
});
}
return undefined;
}

View File

@ -1,33 +1,41 @@
import {
buildXaiModelDefinition,
XAI_BASE_URL,
XAI_DEFAULT_MODEL_ID,
} from "openclaw/plugin-sdk/provider-models";
import { XAI_BASE_URL, XAI_DEFAULT_MODEL_ID } from "openclaw/plugin-sdk/provider-models";
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithDefaultModel,
applyProviderConfigWithDefaultModels,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { buildXaiCatalogModels } from "./model-definitions.js";
export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`;
export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
function applyXaiProviderConfigWithApi(
cfg: OpenClawConfig,
api: "openai-completions" | "openai-responses",
): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[XAI_DEFAULT_MODEL_REF] = {
...models[XAI_DEFAULT_MODEL_REF],
alias: models[XAI_DEFAULT_MODEL_REF]?.alias ?? "Grok",
};
return applyProviderConfigWithDefaultModel(cfg, {
return applyProviderConfigWithDefaultModels(cfg, {
agentModels: models,
providerId: "xai",
api: "openai-completions",
api,
baseUrl: XAI_BASE_URL,
defaultModel: buildXaiModelDefinition(),
defaultModels: buildXaiCatalogModels(),
defaultModelId: XAI_DEFAULT_MODEL_ID,
});
}
export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyXaiProviderConfigWithApi(cfg, "openai-completions");
}
export function applyXaiResponsesApiConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyXaiProviderConfigWithApi(cfg, "openai-responses");
}
export function applyXaiConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(applyXaiProviderConfig(cfg), XAI_DEFAULT_MODEL_REF);
}

View File

@ -0,0 +1,12 @@
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-models";
import { buildXaiCatalogModels, XAI_BASE_URL } from "./model-definitions.js";
export function buildXaiProvider(
api: ModelProviderConfig["api"] = "openai-completions",
): ModelProviderConfig {
return {
baseUrl: XAI_BASE_URL,
api,
models: buildXaiCatalogModels(),
};
}

View File

@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { resolveXaiCatalogEntry } from "./model-definitions.js";
import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
describe("xai provider models", () => {
it("publishes the newer Grok fast and code models in the bundled catalog", () => {
expect(resolveXaiCatalogEntry("grok-4-1-fast-reasoning")).toMatchObject({
id: "grok-4-1-fast-reasoning",
reasoning: true,
contextWindow: 2_000_000,
});
expect(resolveXaiCatalogEntry("grok-code-fast-1")).toMatchObject({
id: "grok-code-fast-1",
reasoning: true,
contextWindow: 256_000,
});
});
it("marks current Grok families as modern while excluding multi-agent ids", () => {
expect(isModernXaiModel("grok-4.20-experimental-beta-0304-reasoning")).toBe(true);
expect(isModernXaiModel("grok-code-fast-1")).toBe(true);
expect(isModernXaiModel("grok-3-mini-fast")).toBe(false);
expect(isModernXaiModel("grok-4.20-multi-agent-experimental-beta-0304")).toBe(false);
});
it("builds forward-compatible runtime models for newer Grok ids", () => {
const grok41 = resolveXaiForwardCompatModel({
providerId: "xai",
ctx: {
provider: "xai",
modelId: "grok-4-1-fast-reasoning",
modelRegistry: { find: () => null } as never,
providerConfig: {
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
},
},
});
const grok420 = resolveXaiForwardCompatModel({
providerId: "xai",
ctx: {
provider: "xai",
modelId: "grok-4.20-experimental-beta-0304-reasoning",
modelRegistry: { find: () => null } as never,
providerConfig: {
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
},
},
});
expect(grok41).toMatchObject({
provider: "xai",
id: "grok-4-1-fast-reasoning",
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
reasoning: true,
contextWindow: 2_000_000,
});
expect(grok420).toMatchObject({
provider: "xai",
id: "grok-4.20-experimental-beta-0304-reasoning",
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
reasoning: true,
contextWindow: 2_000_000,
});
});
it("refuses the unsupported multi-agent endpoint ids", () => {
const model = resolveXaiForwardCompatModel({
providerId: "xai",
ctx: {
provider: "xai",
modelId: "grok-4.20-multi-agent-experimental-beta-0304",
modelRegistry: { find: () => null } as never,
providerConfig: {
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
},
},
});
expect(model).toBeUndefined();
});
});

View File

@ -0,0 +1,41 @@
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { applyXaiModelCompat, normalizeModelCompat } from "openclaw/plugin-sdk/provider-models";
import { resolveXaiCatalogEntry, XAI_BASE_URL } from "./model-definitions.js";
const XAI_MODERN_MODEL_PREFIXES = ["grok-4", "grok-code-fast"] as const;
export function isModernXaiModel(modelId: string): boolean {
const lower = modelId.trim().toLowerCase();
if (!lower || lower.includes("multi-agent")) {
return false;
}
return XAI_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix));
}
export function resolveXaiForwardCompatModel(params: {
providerId: string;
ctx: ProviderResolveDynamicModelContext;
}): ProviderRuntimeModel | undefined {
const definition = resolveXaiCatalogEntry(params.ctx.modelId);
if (!definition) {
return undefined;
}
return applyXaiModelCompat(
normalizeModelCompat({
id: definition.id,
name: definition.name,
api: params.ctx.providerConfig?.api ?? "openai-completions",
provider: params.providerId,
baseUrl: params.ctx.providerConfig?.baseUrl ?? XAI_BASE_URL,
reasoning: definition.reasoning,
input: definition.input,
cost: definition.cost,
contextWindow: definition.contextWindow,
maxTokens: definition.maxTokens,
} as ProviderRuntimeModel),
);
}

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

@ -0,0 +1,141 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
function stripUnsupportedStrictFlag(tool: unknown): unknown {
if (!tool || typeof tool !== "object") {
return tool;
}
const toolObj = tool as Record<string, unknown>;
const fn = toolObj.function;
if (!fn || typeof fn !== "object") {
return tool;
}
const fnObj = fn as Record<string, unknown>;
if (typeof fnObj.strict !== "boolean") {
return tool;
}
const nextFunction = { ...fnObj };
delete nextFunction.strict;
return { ...toolObj, function: nextFunction };
}
export function createXaiToolPayloadCompatibilityWrapper(
baseStreamFn: StreamFn | undefined,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
if (Array.isArray(payloadObj.tools)) {
payloadObj.tools = payloadObj.tools.map((tool) => stripUnsupportedStrictFlag(tool));
}
}
return originalOnPayload?.(payload, model);
},
});
};
}
function decodeHtmlEntities(value: string): string {
return value
.replaceAll("&quot;", '"')
.replaceAll("&#34;", '"')
.replaceAll("&apos;", "'")
.replaceAll("&#39;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&#60;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&#62;", ">")
.replaceAll("&amp;", "&")
.replaceAll("&#38;", "&");
}
function decodeHtmlEntitiesInObject(value: unknown): unknown {
if (typeof value === "string") {
return decodeHtmlEntities(value);
}
if (!value || typeof value !== "object") {
return value;
}
if (Array.isArray(value)) {
return value.map((entry) => decodeHtmlEntitiesInObject(entry));
}
const record = value as Record<string, unknown>;
for (const [key, entry] of Object.entries(record)) {
record[key] = decodeHtmlEntitiesInObject(entry);
}
return record;
}
function decodeXaiToolCallArgumentsInMessage(message: unknown): void {
if (!message || typeof message !== "object") {
return;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) {
return;
}
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const typedBlock = block as { type?: unknown; arguments?: unknown };
if (typedBlock.type !== "toolCall" || !typedBlock.arguments) {
continue;
}
if (typeof typedBlock.arguments === "object") {
typedBlock.arguments = decodeHtmlEntitiesInObject(typedBlock.arguments);
}
}
}
function wrapStreamDecodeXaiToolCallArguments(
stream: ReturnType<typeof streamSimple>,
): ReturnType<typeof streamSimple> {
const originalResult = stream.result.bind(stream);
stream.result = async () => {
const message = await originalResult();
decodeXaiToolCallArgumentsInMessage(message);
return message;
};
const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream);
(stream as { [Symbol.asyncIterator]: typeof originalAsyncIterator })[Symbol.asyncIterator] =
function () {
const iterator = originalAsyncIterator();
return {
async next() {
const result = await iterator.next();
if (!result.done && result.value && typeof result.value === "object") {
const event = result.value as { partial?: unknown; message?: unknown };
decodeXaiToolCallArgumentsInMessage(event.partial);
decodeXaiToolCallArgumentsInMessage(event.message);
}
return result;
},
async return(value?: unknown) {
return iterator.return?.(value) ?? { done: true as const, value: undefined };
},
async throw(error?: unknown) {
return iterator.throw?.(error) ?? { done: true as const, value: undefined };
},
};
};
return stream;
}
export function createXaiToolCallArgumentDecodingWrapper(baseStreamFn: StreamFn): StreamFn {
return (model, context, options) => {
const maybeStream = baseStreamFn(model, context, options);
if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) {
return Promise.resolve(maybeStream).then((stream) =>
wrapStreamDecodeXaiToolCallArguments(stream),
);
}
return wrapStreamDecodeXaiToolCallArguments(maybeStream);
};
}

View File

@ -0,0 +1,121 @@
import {
getScopedCredentialValue,
resolveWebSearchProviderCredential,
} from "openclaw/plugin-sdk/provider-web-search";
import { describe, expect, it } from "vitest";
import { withEnv } from "../../src/test-utils/env.js";
import { __testing } from "./web-search.js";
const { extractXaiWebSearchContent, resolveXaiInlineCitations, resolveXaiWebSearchModel } =
__testing;
describe("xai web search config resolution", () => {
it("uses config apiKey when provided", () => {
const searchConfig = { grok: { apiKey: "xai-test-key" } }; // pragma: allowlist secret
expect(
resolveWebSearchProviderCredential({
credentialValue: getScopedCredentialValue(searchConfig, "grok"),
path: "tools.web.search.grok.apiKey",
envVars: ["XAI_API_KEY"],
}),
).toBe("xai-test-key");
});
it("returns undefined when no apiKey is available", () => {
withEnv({ XAI_API_KEY: undefined }, () => {
expect(
resolveWebSearchProviderCredential({
credentialValue: getScopedCredentialValue({}, "grok"),
path: "tools.web.search.grok.apiKey",
envVars: ["XAI_API_KEY"],
}),
).toBeUndefined();
});
});
it("uses default model when not specified", () => {
expect(resolveXaiWebSearchModel({})).toBe("grok-4-1-fast");
expect(resolveXaiWebSearchModel(undefined)).toBe("grok-4-1-fast");
});
it("uses config model when provided", () => {
expect(resolveXaiWebSearchModel({ grok: { model: "grok-4-fast-reasoning" } })).toBe(
"grok-4-fast-reasoning",
);
});
it("defaults inlineCitations to false", () => {
expect(resolveXaiInlineCitations({})).toBe(false);
expect(resolveXaiInlineCitations(undefined)).toBe(false);
});
it("respects inlineCitations config", () => {
expect(resolveXaiInlineCitations({ grok: { inlineCitations: true } })).toBe(true);
expect(resolveXaiInlineCitations({ grok: { inlineCitations: false } })).toBe(false);
});
});
describe("xai web search response parsing", () => {
it("extracts content from Responses API message blocks", () => {
const result = extractXaiWebSearchContent({
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 = extractXaiWebSearchContent({
output: [
{
type: "message",
content: [
{
type: "output_text",
text: "hello with citations",
annotations: [
{ type: "url_citation", url: "https://example.com/a" },
{ type: "url_citation", url: "https://example.com/b" },
{ type: "url_citation", url: "https://example.com/a" },
],
},
],
},
],
});
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 = extractXaiWebSearchContent({ 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 = extractXaiWebSearchContent({});
expect(result.text).toBeUndefined();
expect(result.annotationCitations).toEqual([]);
});
it("extracts output_text blocks directly in output array", () => {
const result = extractXaiWebSearchContent({
output: [
{ type: "web_search_call" },
{
type: "output_text",
text: "direct output text",
annotations: [{ type: "url_citation", url: "https://example.com/direct" }],
},
],
});
expect(result.text).toBe("direct output text");
expect(result.annotationCitations).toEqual(["https://example.com/direct"]);
});
});

View File

@ -0,0 +1,271 @@
import { Type } from "@sinclair/typebox";
import {
DEFAULT_CACHE_TTL_MINUTES,
DEFAULT_TIMEOUT_SECONDS,
getScopedCredentialValue,
normalizeCacheKey,
readCache,
readResponseText,
resolveCacheTtlMs,
resolveTimeoutSeconds,
resolveWebSearchProviderCredential,
setScopedCredentialValue,
withTrustedWebToolsEndpoint,
wrapWebContent,
writeCache,
} from "openclaw/plugin-sdk/provider-web-search";
const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses";
const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast";
const XAI_WEB_SEARCH_CACHE = new Map<
string,
{ value: Record<string, unknown>; insertedAt: number; expiresAt: number }
>();
type XaiWebSearchResponse = {
output?: Array<{
type?: string;
text?: string;
content?: Array<{
type?: string;
text?: string;
annotations?: Array<{
type?: string;
url?: string;
}>;
}>;
annotations?: Array<{
type?: string;
url?: string;
}>;
}>;
output_text?: string;
citations?: string[];
inline_citations?: Array<{
start_index: number;
end_index: number;
url: string;
}>;
};
function extractXaiWebSearchContent(data: XaiWebSearchResponse): {
text: string | undefined;
annotationCitations: string[];
} {
for (const output of data.output ?? []) {
if (output.type === "message") {
for (const block of output.content ?? []) {
if (block.type === "output_text" && typeof block.text === "string" && block.text) {
const urls = (block.annotations ?? [])
.filter(
(annotation) =>
annotation.type === "url_citation" && typeof annotation.url === "string",
)
.map((annotation) => annotation.url as string);
return { text: block.text, annotationCitations: [...new Set(urls)] };
}
}
}
if (output.type === "output_text" && typeof output.text === "string" && output.text) {
const urls = (output.annotations ?? [])
.filter(
(annotation) => annotation.type === "url_citation" && typeof annotation.url === "string",
)
.map((annotation) => annotation.url as string);
return { text: output.text, annotationCitations: [...new Set(urls)] };
}
}
return {
text: typeof data.output_text === "string" ? data.output_text : undefined,
annotationCitations: [],
};
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function resolveXaiWebSearchConfig(
searchConfig?: Record<string, unknown>,
): Record<string, unknown> {
return asRecord(searchConfig?.grok) ?? {};
}
function resolveXaiWebSearchModel(searchConfig?: Record<string, unknown>): string {
const config = resolveXaiWebSearchConfig(searchConfig);
return typeof config.model === "string" && config.model.trim()
? config.model.trim()
: XAI_DEFAULT_WEB_SEARCH_MODEL;
}
function resolveXaiInlineCitations(searchConfig?: Record<string, unknown>): boolean {
return resolveXaiWebSearchConfig(searchConfig).inlineCitations === true;
}
function readQuery(args: Record<string, unknown>): string {
const value = typeof args.query === "string" ? args.query.trim() : "";
if (!value) {
throw new Error("query required");
}
return value;
}
function readCount(args: Record<string, unknown>): number {
const raw = args.count;
const parsed =
typeof raw === "number" && Number.isFinite(raw)
? raw
: typeof raw === "string" && raw.trim()
? Number.parseFloat(raw)
: 5;
return Math.max(1, Math.min(10, Math.trunc(parsed)));
}
async function throwXaiWebSearchApiError(res: Response): Promise<never> {
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
throw new Error(`xAI API error (${res.status}): ${detailResult.text || res.statusText}`);
}
async function runXaiWebSearch(params: {
query: string;
model: string;
apiKey: string;
timeoutSeconds: number;
inlineCitations: boolean;
cacheTtlMs: number;
}): Promise<Record<string, unknown>> {
const cacheKey = normalizeCacheKey(
`grok:${params.model}:${String(params.inlineCitations)}:${params.query}`,
);
const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey);
if (cached) {
return { ...cached.value, cached: true };
}
const startedAt = Date.now();
const payload = await withTrustedWebToolsEndpoint(
{
url: XAI_WEB_SEARCH_ENDPOINT,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: params.model,
input: [{ role: "user", content: params.query }],
tools: [{ type: "web_search" }],
}),
},
},
async ({ response }) => {
if (!response.ok) {
return await throwXaiWebSearchApiError(response);
}
const data = (await response.json()) as XaiWebSearchResponse;
const { text, annotationCitations } = extractXaiWebSearchContent(data);
const citations =
Array.isArray(data.citations) && data.citations.length > 0
? data.citations
: annotationCitations;
return {
query: params.query,
provider: "grok",
model: params.model,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "grok",
wrapped: true,
},
content: wrapWebContent(text ?? "No response", "web_search"),
citations,
...(params.inlineCitations && Array.isArray(data.inline_citations)
? { inlineCitations: data.inline_citations }
: {}),
};
},
);
writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
return payload;
}
export function createXaiWebSearchProvider() {
return {
id: "grok",
label: "Grok (xAI)",
hint: "xAI web-grounded responses",
envVars: ["XAI_API_KEY"],
placeholder: "xai-...",
signupUrl: "https://console.x.ai/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 30,
getCredentialValue: (searchConfig?: Record<string, unknown>) =>
getScopedCredentialValue(searchConfig, "grok"),
setCredentialValue: (searchConfigTarget: Record<string, unknown>, value: unknown) =>
setScopedCredentialValue(searchConfigTarget, "grok", value),
createTool: (ctx: { searchConfig?: Record<string, unknown> }) => ({
description:
"Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.",
parameters: Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
}),
),
}),
execute: async (args: Record<string, unknown>) => {
const apiKey = resolveWebSearchProviderCredential({
credentialValue: getScopedCredentialValue(ctx.searchConfig, "grok"),
path: "tools.web.search.grok.apiKey",
envVars: ["XAI_API_KEY"],
});
if (!apiKey) {
return {
error: "missing_xai_api_key",
message:
"web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const query = readQuery(args);
const count = readCount(args);
return await runXaiWebSearch({
query,
model: resolveXaiWebSearchModel(ctx.searchConfig),
apiKey,
timeoutSeconds: resolveTimeoutSeconds(
(ctx.searchConfig?.timeoutSeconds as number | undefined) ?? undefined,
DEFAULT_TIMEOUT_SECONDS,
),
inlineCitations: resolveXaiInlineCitations(ctx.searchConfig),
cacheTtlMs: resolveCacheTtlMs(
(ctx.searchConfig?.cacheTtlMinutes as number | undefined) ?? undefined,
DEFAULT_CACHE_TTL_MINUTES,
),
});
},
}),
};
}
export const __testing = {
extractXaiWebSearchContent,
resolveXaiWebSearchModel,
resolveXaiInlineCitations,
};

View File

@ -426,6 +426,10 @@
"types": "./dist/plugin-sdk/provider-stream.d.ts",
"default": "./dist/plugin-sdk/provider-stream.js"
},
"./plugin-sdk/provider-tools": {
"types": "./dist/plugin-sdk/provider-tools.d.ts",
"default": "./dist/plugin-sdk/provider-tools.js"
},
"./plugin-sdk/provider-usage": {
"types": "./dist/plugin-sdk/provider-usage.d.ts",
"default": "./dist/plugin-sdk/provider-usage.js"

View File

@ -96,6 +96,7 @@
"provider-models",
"provider-onboard",
"provider-stream",
"provider-tools",
"provider-usage",
"provider-web-search",
"image-generation",

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");