From a8907d80ddaced42d13808803574fcd26d1679a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 21:26:02 -0700 Subject: [PATCH] feat: finish xai provider integration --- docs/providers/index.md | 1 + docs/providers/models.md | 1 + docs/providers/xai.md | 61 ++++ extensions/amazon-bedrock/index.test.ts | 23 ++ extensions/amazon-bedrock/index.ts | 6 + extensions/google/index.ts | 2 + extensions/openai/openai-codex-provider.ts | 2 + extensions/openai/openai-provider.ts | 6 + extensions/openrouter/index.ts | 8 +- extensions/venice/index.ts | 7 + extensions/xai/index.ts | 53 ++- extensions/xai/model-definitions.ts | 129 ++++++- extensions/xai/onboard.ts | 28 +- extensions/xai/provider-catalog.ts | 12 + extensions/xai/provider-models.test.ts | 86 +++++ extensions/xai/provider-models.ts | 41 +++ extensions/xai/stream.ts | 141 ++++++++ extensions/xai/web-search.test.ts | 121 +++++++ extensions/xai/web-search.ts | 271 +++++++++++++++ package.json | 4 + scripts/lib/plugin-sdk-entrypoints.json | 1 + src/agents/model-compat.ts | 62 ++++ src/agents/pi-embedded-runner/compact.ts | 1 + .../extra-params.google.test.ts | 40 +++ src/agents/pi-embedded-runner/extra-params.ts | 120 ------- .../extra-params.xai-tool-payload.test.ts | 74 ++++ .../extra-params.zai-tool-stream.test.ts | 47 ++- .../google-stream-wrappers.ts | 92 +++++ .../model.forward-compat.test.ts | 23 ++ src/agents/pi-embedded-runner/run/attempt.ts | 5 +- .../pi-embedded-runner/zai-stream-wrappers.ts | 8 +- ...e-aliases-schemas-without-dropping.test.ts | 14 + .../pi-tools.model-provider-collision.test.ts | 19 +- src/agents/pi-tools.schema.ts | 10 +- src/agents/pi-tools.ts | 17 +- src/agents/schema/clean-for-xai.test.ts | 44 +-- src/agents/schema/clean-for-xai.ts | 65 +--- .../tools/web-search-provider-credentials.ts | 26 ++ src/agents/tools/web-search.test.ts | 315 +++++------------- src/agents/tools/web-search.ts | 111 +----- src/agents/xai.live.test.ts | 169 ++++++++++ src/commands/onboard-auth.test.ts | 9 +- src/config/types.models.ts | 3 + src/plugin-sdk/provider-models.ts | 10 +- src/plugin-sdk/provider-stream.ts | 17 +- src/plugin-sdk/provider-tools.ts | 56 ++++ src/plugin-sdk/provider-web-search.ts | 4 + src/plugins/bundled-dir.test.ts | 25 ++ src/plugins/bundled-dir.ts | 6 + .../contracts/runtime.contract.test.ts | 114 +++++++ 50 files changed, 1900 insertions(+), 610 deletions(-) create mode 100644 docs/providers/xai.md create mode 100644 extensions/xai/provider-catalog.ts create mode 100644 extensions/xai/provider-models.test.ts create mode 100644 extensions/xai/provider-models.ts create mode 100644 extensions/xai/stream.ts create mode 100644 extensions/xai/web-search.test.ts create mode 100644 extensions/xai/web-search.ts create mode 100644 src/agents/pi-embedded-runner/extra-params.google.test.ts create mode 100644 src/agents/pi-embedded-runner/extra-params.xai-tool-payload.test.ts create mode 100644 src/agents/pi-embedded-runner/google-stream-wrappers.ts create mode 100644 src/agents/tools/web-search-provider-credentials.ts create mode 100644 src/agents/xai.live.test.ts create mode 100644 src/plugin-sdk/provider-tools.ts diff --git a/docs/providers/index.md b/docs/providers/index.md index f68cd0e0b53..82e30575bc8 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -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) diff --git a/docs/providers/models.md b/docs/providers/models.md index a117d286051..7c8c8c758f6 100644 --- a/docs/providers/models.md +++ b/docs/providers/models.md @@ -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). diff --git a/docs/providers/xai.md b/docs/providers/xai.md new file mode 100644 index 00000000000..ec491735e50 --- /dev/null +++ b/docs/providers/xai.md @@ -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). diff --git a/extensions/amazon-bedrock/index.test.ts b/extensions/amazon-bedrock/index.test.ts index 4afa67e3501..87ce6f6dcd2 100644 --- a/extensions/amazon-bedrock/index.test.ts +++ b/extensions/amazon-bedrock/index.test.ts @@ -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", + }); + }); }); diff --git a/extensions/amazon-bedrock/index.ts b/extensions/amazon-bedrock/index.ts index 9158ab158d7..01c7f62687b 100644 --- a/extensions/amazon-bedrock/index.ts +++ b/extensions/amazon-bedrock/index.ts @@ -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, }); diff --git a/extensions/google/index.ts b/extensions/google/index.ts index e168a346d70..7a67f614d1d 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -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); diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 02407d3879a..5714b09a7d0 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -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; diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 17053e29e69..25c7dc95da9 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -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) => { diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index 3d20250e760..bcb75ecb49d 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -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; diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index 2565049647e..cdf984bb99e 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -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, }); }, }); diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 7a64abca8d3..6fa925637b8 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -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()); }, }); diff --git a/extensions/xai/model-definitions.ts b/extensions/xai/model-definitions.ts index ff3a892500e..87d18484264 100644 --- a/extensions/xai/model-definitions.ts +++ b/extensions/xai/model-definitions.ts @@ -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; +} diff --git a/extensions/xai/onboard.ts b/extensions/xai/onboard.ts index 6abc7477e6c..a4d4b876c1e 100644 --- a/extensions/xai/onboard.ts +++ b/extensions/xai/onboard.ts @@ -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); } diff --git a/extensions/xai/provider-catalog.ts b/extensions/xai/provider-catalog.ts new file mode 100644 index 00000000000..ee56e905f1e --- /dev/null +++ b/extensions/xai/provider-catalog.ts @@ -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(), + }; +} diff --git a/extensions/xai/provider-models.test.ts b/extensions/xai/provider-models.test.ts new file mode 100644 index 00000000000..175209f4975 --- /dev/null +++ b/extensions/xai/provider-models.test.ts @@ -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(); + }); +}); diff --git a/extensions/xai/provider-models.ts b/extensions/xai/provider-models.ts new file mode 100644 index 00000000000..e32282751df --- /dev/null +++ b/extensions/xai/provider-models.ts @@ -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), + ); +} diff --git a/extensions/xai/stream.ts b/extensions/xai/stream.ts new file mode 100644 index 00000000000..390d9ac201d --- /dev/null +++ b/extensions/xai/stream.ts @@ -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; + const fn = toolObj.function; + if (!fn || typeof fn !== "object") { + return tool; + } + const fnObj = fn as Record; + 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; + 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(""", '"') + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(">", ">") + .replaceAll("&", "&") + .replaceAll("&", "&"); +} + +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; + 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, +): ReturnType { + 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); + }; +} diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts new file mode 100644 index 00000000000..0c15d09864c --- /dev/null +++ b/extensions/xai/web-search.test.ts @@ -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"]); + }); +}); diff --git a/extensions/xai/web-search.ts b/extensions/xai/web-search.ts new file mode 100644 index 00000000000..5728731d7ab --- /dev/null +++ b/extensions/xai/web-search.ts @@ -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; 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 | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function resolveXaiWebSearchConfig( + searchConfig?: Record, +): Record { + return asRecord(searchConfig?.grok) ?? {}; +} + +function resolveXaiWebSearchModel(searchConfig?: Record): 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): boolean { + return resolveXaiWebSearchConfig(searchConfig).inlineCitations === true; +} + +function readQuery(args: Record): string { + const value = typeof args.query === "string" ? args.query.trim() : ""; + if (!value) { + throw new Error("query required"); + } + return value; +} + +function readCount(args: Record): 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 { + 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> { + 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) => + getScopedCredentialValue(searchConfig, "grok"), + setCredentialValue: (searchConfigTarget: Record, value: unknown) => + setScopedCredentialValue(searchConfigTarget, "grok", value), + createTool: (ctx: { searchConfig?: Record }) => ({ + 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) => { + 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, +}; diff --git a/package.json b/package.json index ba0c925fded..fdf93d32b30 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 22152408e54..237f69282f2 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -96,6 +96,7 @@ "provider-models", "provider-onboard", "provider-stream", + "provider-tools", "provider-usage", "provider-web-search", "image-generation", diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index 26522da6e67..efdad0e4958 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -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( + 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(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): model is Model<"openai-completions"> { return model.api === "openai-completions"; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 8c46de5c165..7dba07dd2cb 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -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), }); diff --git a/src/agents/pi-embedded-runner/extra-params.google.test.ts b/src/agents/pi-embedded-runner/extra-params.google.test.ts new file mode 100644 index 00000000000..4cf33f5eeef --- /dev/null +++ b/src/agents/pi-embedded-runner/extra-params.google.test.ts @@ -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; + }; + }; + + expect(payload.config?.thinkingConfig?.thinkingBudget).toBeUndefined(); + expect(payload.config?.thinkingConfig?.thinkingLevel).toBe("HIGH"); + }); +}); diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 3008fd97904..8ed61d4aeff 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -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; - const config = payloadObj.config; - if (!config || typeof config !== "object") { - return; - } - const configObj = config as Record; - const thinkingConfig = configObj.thinkingConfig; - if (!thinkingConfig || typeof thinkingConfig !== "object") { - return; - } - const thinkingConfigObj = thinkingConfig as Record; - 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 | 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}`); diff --git a/src/agents/pi-embedded-runner/extra-params.xai-tool-payload.test.ts b/src/agents/pi-embedded-runner/extra-params.xai-tool-payload.test.ts new file mode 100644 index 00000000000..0e1fb50ca80 --- /dev/null +++ b/src/agents/pi-embedded-runner/extra-params.xai-tool-payload.test.ts @@ -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 }>; + }; + + 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 }>; + }; + + expect(payload.tools?.[0]?.function?.strict).toBe(true); + }); +}); diff --git a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts b/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts index ca22149990f..e1817218512 100644 --- a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts @@ -30,7 +30,7 @@ function runToolStreamCase(params: ToolStreamCase) { }).payload as Record; } -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"); + }); }); diff --git a/src/agents/pi-embedded-runner/google-stream-wrappers.ts b/src/agents/pi-embedded-runner/google-stream-wrappers.ts new file mode 100644 index 00000000000..eb69458854e --- /dev/null +++ b/src/agents/pi-embedded-runner/google-stream-wrappers.ts @@ -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; + const config = payloadObj.config; + if (!config || typeof config !== "object") { + return; + } + const configObj = config as Record; + const thinkingConfig = configObj.thinkingConfig; + if (!thinkingConfig || typeof thinkingConfig !== "object") { + return; + } + const thinkingConfigObj = thinkingConfig as Record; + 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); + }, + }); + }; +} diff --git a/src/agents/pi-embedded-runner/model.forward-compat.test.ts b/src/agents/pi-embedded-runner/model.forward-compat.test.ts index f0cdc3e29cb..85aad12d0d6 100644 --- a/src/agents/pi-embedded-runner/model.forward-compat.test.ts +++ b/src/agents/pi-embedded-runner/model.forward-compat.test.ts @@ -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(); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 6f8f2f48a32..69d8212adfa 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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, ); diff --git a/src/agents/pi-embedded-runner/zai-stream-wrappers.ts b/src/agents/pi-embedded-runner/zai-stream-wrappers.ts index e6c1077cf5e..0a17059dbed 100644 --- a/src/agents/pi-embedded-runner/zai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/zai-stream-wrappers.ts @@ -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; diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts index 609ff8a2b1e..ca704b03e51 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts @@ -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"); diff --git a/src/agents/pi-tools.model-provider-collision.test.ts b/src/agents/pi-tools.model-provider-collision.test.ts index 7cbceac712e..04eaa575601 100644 --- a/src/agents/pi-tools.model-provider-collision.test.ts +++ b/src/agents/pi-tools.model-provider-collision.test.ts @@ -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"]); diff --git a/src/agents/pi-tools.schema.ts b/src/agents/pi-tools.schema.ts index 407f277645d..01288e75fe8 100644 --- a/src/agents/pi-tools.schema.ts +++ b/src/agents/pi-tools.schema.ts @@ -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; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index b8be63f65e5..4dd9fe379fa 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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) => diff --git a/src/agents/schema/clean-for-xai.test.ts b/src/agents/schema/clean-for-xai.test.ts index 6f9c316c784..b51b829257d 100644 --- a/src/agents/schema/clean-for-xai.test.ts +++ b/src/agents/schema/clean-for-xai.test.ts @@ -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", () => { diff --git a/src/agents/schema/clean-for-xai.ts b/src/agents/schema/clean-for-xai.ts index f11f82629da..aff13a19a1d 100644 --- a/src/agents/schema/clean-for-xai.ts +++ b/src/agents/schema/clean-for-xai.ts @@ -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; - const cleaned: Record = {}; - 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).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 }; diff --git a/src/agents/tools/web-search-provider-credentials.ts b/src/agents/tools/web-search-provider-credentials.ts new file mode 100644 index 00000000000..69d98792171 --- /dev/null +++ b/src/agents/tools/web-search-provider-credentials.ts @@ -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; +} diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 93f6d791bf5..cb9cabfe87f 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -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[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(); }); }); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 340065d0d62..cdd4e18a660 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -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 | 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 | 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, }; diff --git a/src/agents/xai.live.test.ts b/src/agents/xai.live.test.ts new file mode 100644 index 00000000000..a8dcde278db --- /dev/null +++ b/src/agents/xai.live.test.ts @@ -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 { + 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 | 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; + }, + }, + ); + + 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>) + : []; + const firstFunction = payloadTools[0]?.function; + if (firstFunction && typeof firstFunction === "object") { + expect((firstFunction as Record).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; + 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); +}); diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 969128d343e..d245d64f703 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -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", + ]), + ); }); }); diff --git a/src/config/types.models.ts b/src/config/types.models.ts index f244c9d0658..3c8c5debd6a 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -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; diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index 996135c9011..b82bc09dc2f 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -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"; diff --git a/src/plugin-sdk/provider-stream.ts b/src/plugin-sdk/provider-stream.ts index 19b8fe76092..ced7c2d9d4c 100644 --- a/src/plugin-sdk/provider-stream.ts +++ b/src/plugin-sdk/provider-stream.ts @@ -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, diff --git a/src/plugin-sdk/provider-tools.ts b/src/plugin-sdk/provider-tools.ts new file mode 100644 index 00000000000..33cbbb3c784 --- /dev/null +++ b/src/plugin-sdk/provider-tools.ts @@ -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, +): unknown { + if (!schema || typeof schema !== "object") { + return schema; + } + if (Array.isArray(schema)) { + return schema.map((entry) => stripUnsupportedSchemaKeywords(entry, unsupportedKeywords)); + } + const obj = schema as Record; + const cleaned: Record = {}; + 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).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); +} diff --git a/src/plugin-sdk/provider-web-search.ts b/src/plugin-sdk/provider-web-search.ts index 33ccf1b62c7..e158b160a6f 100644 --- a/src/plugin-sdk/provider-web-search.ts +++ b/src/plugin-sdk/provider-web-search.ts @@ -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 diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index 93f53acaf75..fd978ec7069 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -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")), + ); + }); }); diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 8b5ffdd5c4d..6614a50aed0 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -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. diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index 385bfe8a3bd..f3985500af4 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -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");