feat: finish xai provider integration
This commit is contained in:
parent
2b5fa0931d
commit
a8907d80dd
@ -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)
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ model as `provider/model`.
|
||||
- [Venice (Venice AI)](/providers/venice)
|
||||
- [Amazon Bedrock](/providers/bedrock)
|
||||
- [Qianfan](/providers/qianfan)
|
||||
- [xAI](/providers/xai)
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
see [Model providers](/concepts/model-providers).
|
||||
|
||||
61
docs/providers/xai.md
Normal file
61
docs/providers/xai.md
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
summary: "Use xAI Grok models in OpenClaw"
|
||||
read_when:
|
||||
- You want to use Grok models in OpenClaw
|
||||
- You are configuring xAI auth or model ids
|
||||
title: "xAI"
|
||||
---
|
||||
|
||||
# xAI
|
||||
|
||||
OpenClaw ships a bundled `xai` provider plugin for Grok models.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create an API key in the xAI console.
|
||||
2. Set `XAI_API_KEY`, or run:
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice xai-api-key
|
||||
```
|
||||
|
||||
3. Pick a model such as:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { model: { primary: "xai/grok-4" } } },
|
||||
}
|
||||
```
|
||||
|
||||
## Current bundled model catalog
|
||||
|
||||
OpenClaw now includes these xAI model families out of the box:
|
||||
|
||||
- `grok-4`, `grok-4-0709`
|
||||
- `grok-4-fast-reasoning`, `grok-4-fast-non-reasoning`
|
||||
- `grok-4-1-fast-reasoning`, `grok-4-1-fast-non-reasoning`
|
||||
- `grok-4.20-experimental-beta-0304-reasoning`
|
||||
- `grok-4.20-experimental-beta-0304-non-reasoning`
|
||||
- `grok-code-fast-1`
|
||||
|
||||
The plugin also forward-resolves newer `grok-4*` and `grok-code-fast*` ids when
|
||||
they follow the same API shape.
|
||||
|
||||
## Web search
|
||||
|
||||
The bundled `grok` web-search provider uses `XAI_API_KEY` too:
|
||||
|
||||
```bash
|
||||
openclaw config set tools.web.search.provider grok
|
||||
```
|
||||
|
||||
## Known limits
|
||||
|
||||
- Auth is API-key only today. There is no xAI OAuth/device-code flow in OpenClaw yet.
|
||||
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the normal xAI provider path because it requires a different upstream API surface than the standard OpenClaw xAI transport.
|
||||
- Native xAI server-side tools such as `x_search` and `code_execution` are not yet first-class model-provider features in the bundled plugin.
|
||||
|
||||
## Notes
|
||||
|
||||
- OpenClaw applies xAI-specific tool-schema and tool-call compatibility fixes automatically on the shared runner path.
|
||||
- For the broader provider overview, see [Model providers](/providers/index).
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -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());
|
||||
},
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
12
extensions/xai/provider-catalog.ts
Normal file
12
extensions/xai/provider-catalog.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-models";
|
||||
import { buildXaiCatalogModels, XAI_BASE_URL } from "./model-definitions.js";
|
||||
|
||||
export function buildXaiProvider(
|
||||
api: ModelProviderConfig["api"] = "openai-completions",
|
||||
): ModelProviderConfig {
|
||||
return {
|
||||
baseUrl: XAI_BASE_URL,
|
||||
api,
|
||||
models: buildXaiCatalogModels(),
|
||||
};
|
||||
}
|
||||
86
extensions/xai/provider-models.test.ts
Normal file
86
extensions/xai/provider-models.test.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveXaiCatalogEntry } from "./model-definitions.js";
|
||||
import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
|
||||
|
||||
describe("xai provider models", () => {
|
||||
it("publishes the newer Grok fast and code models in the bundled catalog", () => {
|
||||
expect(resolveXaiCatalogEntry("grok-4-1-fast-reasoning")).toMatchObject({
|
||||
id: "grok-4-1-fast-reasoning",
|
||||
reasoning: true,
|
||||
contextWindow: 2_000_000,
|
||||
});
|
||||
expect(resolveXaiCatalogEntry("grok-code-fast-1")).toMatchObject({
|
||||
id: "grok-code-fast-1",
|
||||
reasoning: true,
|
||||
contextWindow: 256_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks current Grok families as modern while excluding multi-agent ids", () => {
|
||||
expect(isModernXaiModel("grok-4.20-experimental-beta-0304-reasoning")).toBe(true);
|
||||
expect(isModernXaiModel("grok-code-fast-1")).toBe(true);
|
||||
expect(isModernXaiModel("grok-3-mini-fast")).toBe(false);
|
||||
expect(isModernXaiModel("grok-4.20-multi-agent-experimental-beta-0304")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds forward-compatible runtime models for newer Grok ids", () => {
|
||||
const grok41 = resolveXaiForwardCompatModel({
|
||||
providerId: "xai",
|
||||
ctx: {
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast-reasoning",
|
||||
modelRegistry: { find: () => null } as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const grok420 = resolveXaiForwardCompatModel({
|
||||
providerId: "xai",
|
||||
ctx: {
|
||||
provider: "xai",
|
||||
modelId: "grok-4.20-experimental-beta-0304-reasoning",
|
||||
modelRegistry: { find: () => null } as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(grok41).toMatchObject({
|
||||
provider: "xai",
|
||||
id: "grok-4-1-fast-reasoning",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
contextWindow: 2_000_000,
|
||||
});
|
||||
expect(grok420).toMatchObject({
|
||||
provider: "xai",
|
||||
id: "grok-4.20-experimental-beta-0304-reasoning",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
contextWindow: 2_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses the unsupported multi-agent endpoint ids", () => {
|
||||
const model = resolveXaiForwardCompatModel({
|
||||
providerId: "xai",
|
||||
ctx: {
|
||||
provider: "xai",
|
||||
modelId: "grok-4.20-multi-agent-experimental-beta-0304",
|
||||
modelRegistry: { find: () => null } as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(model).toBeUndefined();
|
||||
});
|
||||
});
|
||||
41
extensions/xai/provider-models.ts
Normal file
41
extensions/xai/provider-models.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type {
|
||||
ProviderResolveDynamicModelContext,
|
||||
ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { applyXaiModelCompat, normalizeModelCompat } from "openclaw/plugin-sdk/provider-models";
|
||||
import { resolveXaiCatalogEntry, XAI_BASE_URL } from "./model-definitions.js";
|
||||
|
||||
const XAI_MODERN_MODEL_PREFIXES = ["grok-4", "grok-code-fast"] as const;
|
||||
|
||||
export function isModernXaiModel(modelId: string): boolean {
|
||||
const lower = modelId.trim().toLowerCase();
|
||||
if (!lower || lower.includes("multi-agent")) {
|
||||
return false;
|
||||
}
|
||||
return XAI_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function resolveXaiForwardCompatModel(params: {
|
||||
providerId: string;
|
||||
ctx: ProviderResolveDynamicModelContext;
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
const definition = resolveXaiCatalogEntry(params.ctx.modelId);
|
||||
if (!definition) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return applyXaiModelCompat(
|
||||
normalizeModelCompat({
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
api: params.ctx.providerConfig?.api ?? "openai-completions",
|
||||
provider: params.providerId,
|
||||
baseUrl: params.ctx.providerConfig?.baseUrl ?? XAI_BASE_URL,
|
||||
reasoning: definition.reasoning,
|
||||
input: definition.input,
|
||||
cost: definition.cost,
|
||||
contextWindow: definition.contextWindow,
|
||||
maxTokens: definition.maxTokens,
|
||||
} as ProviderRuntimeModel),
|
||||
);
|
||||
}
|
||||
141
extensions/xai/stream.ts
Normal file
141
extensions/xai/stream.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
|
||||
function stripUnsupportedStrictFlag(tool: unknown): unknown {
|
||||
if (!tool || typeof tool !== "object") {
|
||||
return tool;
|
||||
}
|
||||
const toolObj = tool as Record<string, unknown>;
|
||||
const fn = toolObj.function;
|
||||
if (!fn || typeof fn !== "object") {
|
||||
return tool;
|
||||
}
|
||||
const fnObj = fn as Record<string, unknown>;
|
||||
if (typeof fnObj.strict !== "boolean") {
|
||||
return tool;
|
||||
}
|
||||
const nextFunction = { ...fnObj };
|
||||
delete nextFunction.strict;
|
||||
return { ...toolObj, function: nextFunction };
|
||||
}
|
||||
|
||||
export function createXaiToolPayloadCompatibilityWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
const originalOnPayload = options?.onPayload;
|
||||
return underlying(model, context, {
|
||||
...options,
|
||||
onPayload: (payload) => {
|
||||
if (payload && typeof payload === "object") {
|
||||
const payloadObj = payload as Record<string, unknown>;
|
||||
if (Array.isArray(payloadObj.tools)) {
|
||||
payloadObj.tools = payloadObj.tools.map((tool) => stripUnsupportedStrictFlag(tool));
|
||||
}
|
||||
}
|
||||
return originalOnPayload?.(payload, model);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value
|
||||
.replaceAll(""", '"')
|
||||
.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<string, unknown>;
|
||||
for (const [key, entry] of Object.entries(record)) {
|
||||
record[key] = decodeHtmlEntitiesInObject(entry);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function decodeXaiToolCallArgumentsInMessage(message: unknown): void {
|
||||
if (!message || typeof message !== "object") {
|
||||
return;
|
||||
}
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) {
|
||||
return;
|
||||
}
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const typedBlock = block as { type?: unknown; arguments?: unknown };
|
||||
if (typedBlock.type !== "toolCall" || !typedBlock.arguments) {
|
||||
continue;
|
||||
}
|
||||
if (typeof typedBlock.arguments === "object") {
|
||||
typedBlock.arguments = decodeHtmlEntitiesInObject(typedBlock.arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wrapStreamDecodeXaiToolCallArguments(
|
||||
stream: ReturnType<typeof streamSimple>,
|
||||
): ReturnType<typeof streamSimple> {
|
||||
const originalResult = stream.result.bind(stream);
|
||||
stream.result = async () => {
|
||||
const message = await originalResult();
|
||||
decodeXaiToolCallArgumentsInMessage(message);
|
||||
return message;
|
||||
};
|
||||
|
||||
const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream);
|
||||
(stream as { [Symbol.asyncIterator]: typeof originalAsyncIterator })[Symbol.asyncIterator] =
|
||||
function () {
|
||||
const iterator = originalAsyncIterator();
|
||||
return {
|
||||
async next() {
|
||||
const result = await iterator.next();
|
||||
if (!result.done && result.value && typeof result.value === "object") {
|
||||
const event = result.value as { partial?: unknown; message?: unknown };
|
||||
decodeXaiToolCallArgumentsInMessage(event.partial);
|
||||
decodeXaiToolCallArgumentsInMessage(event.message);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async return(value?: unknown) {
|
||||
return iterator.return?.(value) ?? { done: true as const, value: undefined };
|
||||
},
|
||||
async throw(error?: unknown) {
|
||||
return iterator.throw?.(error) ?? { done: true as const, value: undefined };
|
||||
},
|
||||
};
|
||||
};
|
||||
return stream;
|
||||
}
|
||||
|
||||
export function createXaiToolCallArgumentDecodingWrapper(baseStreamFn: StreamFn): StreamFn {
|
||||
return (model, context, options) => {
|
||||
const maybeStream = baseStreamFn(model, context, options);
|
||||
if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) {
|
||||
return Promise.resolve(maybeStream).then((stream) =>
|
||||
wrapStreamDecodeXaiToolCallArguments(stream),
|
||||
);
|
||||
}
|
||||
return wrapStreamDecodeXaiToolCallArguments(maybeStream);
|
||||
};
|
||||
}
|
||||
121
extensions/xai/web-search.test.ts
Normal file
121
extensions/xai/web-search.test.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import {
|
||||
getScopedCredentialValue,
|
||||
resolveWebSearchProviderCredential,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnv } from "../../src/test-utils/env.js";
|
||||
import { __testing } from "./web-search.js";
|
||||
|
||||
const { extractXaiWebSearchContent, resolveXaiInlineCitations, resolveXaiWebSearchModel } =
|
||||
__testing;
|
||||
|
||||
describe("xai web search config resolution", () => {
|
||||
it("uses config apiKey when provided", () => {
|
||||
const searchConfig = { grok: { apiKey: "xai-test-key" } }; // pragma: allowlist secret
|
||||
expect(
|
||||
resolveWebSearchProviderCredential({
|
||||
credentialValue: getScopedCredentialValue(searchConfig, "grok"),
|
||||
path: "tools.web.search.grok.apiKey",
|
||||
envVars: ["XAI_API_KEY"],
|
||||
}),
|
||||
).toBe("xai-test-key");
|
||||
});
|
||||
|
||||
it("returns undefined when no apiKey is available", () => {
|
||||
withEnv({ XAI_API_KEY: undefined }, () => {
|
||||
expect(
|
||||
resolveWebSearchProviderCredential({
|
||||
credentialValue: getScopedCredentialValue({}, "grok"),
|
||||
path: "tools.web.search.grok.apiKey",
|
||||
envVars: ["XAI_API_KEY"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default model when not specified", () => {
|
||||
expect(resolveXaiWebSearchModel({})).toBe("grok-4-1-fast");
|
||||
expect(resolveXaiWebSearchModel(undefined)).toBe("grok-4-1-fast");
|
||||
});
|
||||
|
||||
it("uses config model when provided", () => {
|
||||
expect(resolveXaiWebSearchModel({ grok: { model: "grok-4-fast-reasoning" } })).toBe(
|
||||
"grok-4-fast-reasoning",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults inlineCitations to false", () => {
|
||||
expect(resolveXaiInlineCitations({})).toBe(false);
|
||||
expect(resolveXaiInlineCitations(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("respects inlineCitations config", () => {
|
||||
expect(resolveXaiInlineCitations({ grok: { inlineCitations: true } })).toBe(true);
|
||||
expect(resolveXaiInlineCitations({ grok: { inlineCitations: false } })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("xai web search response parsing", () => {
|
||||
it("extracts content from Responses API message blocks", () => {
|
||||
const result = extractXaiWebSearchContent({
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
content: [{ type: "output_text", text: "hello from output" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.text).toBe("hello from output");
|
||||
expect(result.annotationCitations).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts url_citation annotations from content blocks", () => {
|
||||
const result = extractXaiWebSearchContent({
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "hello with citations",
|
||||
annotations: [
|
||||
{ type: "url_citation", url: "https://example.com/a" },
|
||||
{ type: "url_citation", url: "https://example.com/b" },
|
||||
{ type: "url_citation", url: "https://example.com/a" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.text).toBe("hello with citations");
|
||||
expect(result.annotationCitations).toEqual(["https://example.com/a", "https://example.com/b"]);
|
||||
});
|
||||
|
||||
it("falls back to deprecated output_text", () => {
|
||||
const result = extractXaiWebSearchContent({ output_text: "hello from output_text" });
|
||||
expect(result.text).toBe("hello from output_text");
|
||||
expect(result.annotationCitations).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns undefined text when no content found", () => {
|
||||
const result = extractXaiWebSearchContent({});
|
||||
expect(result.text).toBeUndefined();
|
||||
expect(result.annotationCitations).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts output_text blocks directly in output array", () => {
|
||||
const result = extractXaiWebSearchContent({
|
||||
output: [
|
||||
{ type: "web_search_call" },
|
||||
{
|
||||
type: "output_text",
|
||||
text: "direct output text",
|
||||
annotations: [{ type: "url_citation", url: "https://example.com/direct" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.text).toBe("direct output text");
|
||||
expect(result.annotationCitations).toEqual(["https://example.com/direct"]);
|
||||
});
|
||||
});
|
||||
271
extensions/xai/web-search.ts
Normal file
271
extensions/xai/web-search.ts
Normal file
@ -0,0 +1,271 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
DEFAULT_CACHE_TTL_MINUTES,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
getScopedCredentialValue,
|
||||
normalizeCacheKey,
|
||||
readCache,
|
||||
readResponseText,
|
||||
resolveCacheTtlMs,
|
||||
resolveTimeoutSeconds,
|
||||
resolveWebSearchProviderCredential,
|
||||
setScopedCredentialValue,
|
||||
withTrustedWebToolsEndpoint,
|
||||
wrapWebContent,
|
||||
writeCache,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
|
||||
const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses";
|
||||
const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast";
|
||||
const XAI_WEB_SEARCH_CACHE = new Map<
|
||||
string,
|
||||
{ value: Record<string, unknown>; insertedAt: number; expiresAt: number }
|
||||
>();
|
||||
|
||||
type XaiWebSearchResponse = {
|
||||
output?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
content?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
}>;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
}>;
|
||||
output_text?: string;
|
||||
citations?: string[];
|
||||
inline_citations?: Array<{
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function extractXaiWebSearchContent(data: XaiWebSearchResponse): {
|
||||
text: string | undefined;
|
||||
annotationCitations: string[];
|
||||
} {
|
||||
for (const output of data.output ?? []) {
|
||||
if (output.type === "message") {
|
||||
for (const block of output.content ?? []) {
|
||||
if (block.type === "output_text" && typeof block.text === "string" && block.text) {
|
||||
const urls = (block.annotations ?? [])
|
||||
.filter(
|
||||
(annotation) =>
|
||||
annotation.type === "url_citation" && typeof annotation.url === "string",
|
||||
)
|
||||
.map((annotation) => annotation.url as string);
|
||||
return { text: block.text, annotationCitations: [...new Set(urls)] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (output.type === "output_text" && typeof output.text === "string" && output.text) {
|
||||
const urls = (output.annotations ?? [])
|
||||
.filter(
|
||||
(annotation) => annotation.type === "url_citation" && typeof annotation.url === "string",
|
||||
)
|
||||
.map((annotation) => annotation.url as string);
|
||||
return { text: output.text, annotationCitations: [...new Set(urls)] };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: typeof data.output_text === "string" ? data.output_text : undefined,
|
||||
annotationCitations: [],
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveXaiWebSearchConfig(
|
||||
searchConfig?: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return asRecord(searchConfig?.grok) ?? {};
|
||||
}
|
||||
|
||||
function resolveXaiWebSearchModel(searchConfig?: Record<string, unknown>): string {
|
||||
const config = resolveXaiWebSearchConfig(searchConfig);
|
||||
return typeof config.model === "string" && config.model.trim()
|
||||
? config.model.trim()
|
||||
: XAI_DEFAULT_WEB_SEARCH_MODEL;
|
||||
}
|
||||
|
||||
function resolveXaiInlineCitations(searchConfig?: Record<string, unknown>): boolean {
|
||||
return resolveXaiWebSearchConfig(searchConfig).inlineCitations === true;
|
||||
}
|
||||
|
||||
function readQuery(args: Record<string, unknown>): string {
|
||||
const value = typeof args.query === "string" ? args.query.trim() : "";
|
||||
if (!value) {
|
||||
throw new Error("query required");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readCount(args: Record<string, unknown>): number {
|
||||
const raw = args.count;
|
||||
const parsed =
|
||||
typeof raw === "number" && Number.isFinite(raw)
|
||||
? raw
|
||||
: typeof raw === "string" && raw.trim()
|
||||
? Number.parseFloat(raw)
|
||||
: 5;
|
||||
return Math.max(1, Math.min(10, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
async function throwXaiWebSearchApiError(res: Response): Promise<never> {
|
||||
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
|
||||
throw new Error(`xAI API error (${res.status}): ${detailResult.text || res.statusText}`);
|
||||
}
|
||||
|
||||
async function runXaiWebSearch(params: {
|
||||
query: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
inlineCitations: boolean;
|
||||
cacheTtlMs: number;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`grok:${params.model}:${String(params.inlineCitations)}:${params.query}`,
|
||||
);
|
||||
const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey);
|
||||
if (cached) {
|
||||
return { ...cached.value, cached: true };
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const payload = await withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: XAI_WEB_SEARCH_ENDPOINT,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
input: [{ role: "user", content: params.query }],
|
||||
tools: [{ type: "web_search" }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ response }) => {
|
||||
if (!response.ok) {
|
||||
return await throwXaiWebSearchApiError(response);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as XaiWebSearchResponse;
|
||||
const { text, annotationCitations } = extractXaiWebSearchContent(data);
|
||||
const citations =
|
||||
Array.isArray(data.citations) && data.citations.length > 0
|
||||
? data.citations
|
||||
: annotationCitations;
|
||||
|
||||
return {
|
||||
query: params.query,
|
||||
provider: "grok",
|
||||
model: params.model,
|
||||
tookMs: Date.now() - startedAt,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "grok",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(text ?? "No response", "web_search"),
|
||||
citations,
|
||||
...(params.inlineCitations && Array.isArray(data.inline_citations)
|
||||
? { inlineCitations: data.inline_citations }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createXaiWebSearchProvider() {
|
||||
return {
|
||||
id: "grok",
|
||||
label: "Grok (xAI)",
|
||||
hint: "xAI web-grounded responses",
|
||||
envVars: ["XAI_API_KEY"],
|
||||
placeholder: "xai-...",
|
||||
signupUrl: "https://console.x.ai/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 30,
|
||||
getCredentialValue: (searchConfig?: Record<string, unknown>) =>
|
||||
getScopedCredentialValue(searchConfig, "grok"),
|
||||
setCredentialValue: (searchConfigTarget: Record<string, unknown>, value: unknown) =>
|
||||
setScopedCredentialValue(searchConfigTarget, "grok", value),
|
||||
createTool: (ctx: { searchConfig?: Record<string, unknown> }) => ({
|
||||
description:
|
||||
"Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.",
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
execute: async (args: Record<string, unknown>) => {
|
||||
const apiKey = resolveWebSearchProviderCredential({
|
||||
credentialValue: getScopedCredentialValue(ctx.searchConfig, "grok"),
|
||||
path: "tools.web.search.grok.apiKey",
|
||||
envVars: ["XAI_API_KEY"],
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error: "missing_xai_api_key",
|
||||
message:
|
||||
"web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const query = readQuery(args);
|
||||
const count = readCount(args);
|
||||
return await runXaiWebSearch({
|
||||
query,
|
||||
model: resolveXaiWebSearchModel(ctx.searchConfig),
|
||||
apiKey,
|
||||
timeoutSeconds: resolveTimeoutSeconds(
|
||||
(ctx.searchConfig?.timeoutSeconds as number | undefined) ?? undefined,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
),
|
||||
inlineCitations: resolveXaiInlineCitations(ctx.searchConfig),
|
||||
cacheTtlMs: resolveCacheTtlMs(
|
||||
(ctx.searchConfig?.cacheTtlMinutes as number | undefined) ?? undefined,
|
||||
DEFAULT_CACHE_TTL_MINUTES,
|
||||
),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
extractXaiWebSearchContent,
|
||||
resolveXaiWebSearchModel,
|
||||
resolveXaiInlineCitations,
|
||||
};
|
||||
@ -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"
|
||||
|
||||
@ -96,6 +96,7 @@
|
||||
"provider-models",
|
||||
"provider-onboard",
|
||||
"provider-stream",
|
||||
"provider-tools",
|
||||
"provider-usage",
|
||||
"provider-web-search",
|
||||
"image-generation",
|
||||
|
||||
@ -1,4 +1,66 @@
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import type { ModelCompatConfig } from "../config/types.models.js";
|
||||
|
||||
export const XAI_TOOL_SCHEMA_PROFILE = "xai";
|
||||
export const HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING = "html-entities";
|
||||
|
||||
function extractModelCompat(
|
||||
modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined,
|
||||
): ModelCompatConfig | undefined {
|
||||
if (!modelOrCompat || typeof modelOrCompat !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if ("compat" in modelOrCompat) {
|
||||
const compat = (modelOrCompat as { compat?: unknown }).compat;
|
||||
return compat && typeof compat === "object" ? (compat as ModelCompatConfig) : undefined;
|
||||
}
|
||||
return modelOrCompat as ModelCompatConfig;
|
||||
}
|
||||
|
||||
export function applyModelCompatPatch<T extends { compat?: ModelCompatConfig }>(
|
||||
model: T,
|
||||
patch: ModelCompatConfig,
|
||||
): T {
|
||||
const nextCompat = { ...model.compat, ...patch };
|
||||
if (
|
||||
model.compat &&
|
||||
Object.entries(patch).every(
|
||||
([key, value]) => model.compat?.[key as keyof ModelCompatConfig] === value,
|
||||
)
|
||||
) {
|
||||
return model;
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
compat: nextCompat,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyXaiModelCompat<T extends { compat?: ModelCompatConfig }>(model: T): T {
|
||||
return applyModelCompatPatch(model, {
|
||||
toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE,
|
||||
nativeWebSearchTool: true,
|
||||
toolCallArgumentsEncoding: HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING,
|
||||
});
|
||||
}
|
||||
|
||||
export function usesXaiToolSchemaProfile(
|
||||
modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined,
|
||||
): boolean {
|
||||
return extractModelCompat(modelOrCompat)?.toolSchemaProfile === XAI_TOOL_SCHEMA_PROFILE;
|
||||
}
|
||||
|
||||
export function hasNativeWebSearchTool(
|
||||
modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined,
|
||||
): boolean {
|
||||
return extractModelCompat(modelOrCompat)?.nativeWebSearchTool === true;
|
||||
}
|
||||
|
||||
export function resolveToolCallArgumentsEncoding(
|
||||
modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined,
|
||||
): ModelCompatConfig["toolCallArgumentsEncoding"] | undefined {
|
||||
return extractModelCompat(modelOrCompat)?.toolCallArgumentsEncoding;
|
||||
}
|
||||
|
||||
function isOpenAiCompletionsModel(model: Model<Api>): model is Model<"openai-completions"> {
|
||||
return model.api === "openai-completions";
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
40
src/agents/pi-embedded-runner/extra-params.google.test.ts
Normal file
40
src/agents/pi-embedded-runner/extra-params.google.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runExtraParamsCase } from "./extra-params.test-support.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
streamSimple: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
result: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("extra-params: Google thinking payload compatibility", () => {
|
||||
it("strips negative thinking budgets and fills Gemini 3.1 thinkingLevel", () => {
|
||||
const payload = runExtraParamsCase({
|
||||
applyProvider: "google",
|
||||
applyModelId: "gemini-3.1-pro-preview",
|
||||
model: {
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
id: "gemini-3.1-pro-preview",
|
||||
} as Model<"openai-completions">,
|
||||
thinkingLevel: "high",
|
||||
payload: {
|
||||
contents: [],
|
||||
config: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}).payload as {
|
||||
config?: {
|
||||
thinkingConfig?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(payload.config?.thinkingConfig?.thinkingBudget).toBeUndefined();
|
||||
expect(payload.config?.thinkingConfig?.thinkingLevel).toBe("HIGH");
|
||||
});
|
||||
});
|
||||
@ -11,8 +11,6 @@ import {
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
createAnthropicToolPayloadCompatibilityWrapper,
|
||||
createBedrockNoCacheWrapper,
|
||||
isAnthropicBedrockModel,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicBetas,
|
||||
resolveCacheRetention,
|
||||
@ -26,15 +24,12 @@ import {
|
||||
shouldApplySiliconFlowThinkingOffCompat,
|
||||
} from "./moonshot-stream-wrappers.js";
|
||||
import {
|
||||
createOpenAIAttributionHeadersWrapper,
|
||||
createOpenAIDefaultTransportWrapper,
|
||||
createOpenAIFastModeWrapper,
|
||||
createOpenAIResponsesContextManagementWrapper,
|
||||
createOpenAIServiceTierWrapper,
|
||||
resolveOpenAIFastMode,
|
||||
resolveOpenAIServiceTier,
|
||||
} from "./openai-stream-wrappers.js";
|
||||
import { createZaiToolStreamWrapper } from "./zai-stream-wrappers.js";
|
||||
|
||||
/**
|
||||
* Resolve provider-specific extra params from model config.
|
||||
@ -127,95 +122,6 @@ function createStreamFnWithExtraParams(
|
||||
return wrappedStreamFn;
|
||||
}
|
||||
|
||||
function isGemini31Model(modelId: string): boolean {
|
||||
const normalized = modelId.toLowerCase();
|
||||
return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash");
|
||||
}
|
||||
|
||||
function mapThinkLevelToGoogleThinkingLevel(
|
||||
thinkingLevel: ThinkLevel,
|
||||
): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" | undefined {
|
||||
switch (thinkingLevel) {
|
||||
case "minimal":
|
||||
return "MINIMAL";
|
||||
case "low":
|
||||
return "LOW";
|
||||
case "medium":
|
||||
case "adaptive":
|
||||
return "MEDIUM";
|
||||
case "high":
|
||||
case "xhigh":
|
||||
return "HIGH";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeGoogleThinkingPayload(params: {
|
||||
payload: unknown;
|
||||
modelId?: string;
|
||||
thinkingLevel?: ThinkLevel;
|
||||
}): void {
|
||||
if (!params.payload || typeof params.payload !== "object") {
|
||||
return;
|
||||
}
|
||||
const payloadObj = params.payload as Record<string, unknown>;
|
||||
const config = payloadObj.config;
|
||||
if (!config || typeof config !== "object") {
|
||||
return;
|
||||
}
|
||||
const configObj = config as Record<string, unknown>;
|
||||
const thinkingConfig = configObj.thinkingConfig;
|
||||
if (!thinkingConfig || typeof thinkingConfig !== "object") {
|
||||
return;
|
||||
}
|
||||
const thinkingConfigObj = thinkingConfig as Record<string, unknown>;
|
||||
const thinkingBudget = thinkingConfigObj.thinkingBudget;
|
||||
if (typeof thinkingBudget !== "number" || thinkingBudget >= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// pi-ai can emit thinkingBudget=-1 for some Gemini 3.1 IDs; a negative budget
|
||||
// is invalid for Google-compatible backends and can lead to malformed handling.
|
||||
delete thinkingConfigObj.thinkingBudget;
|
||||
|
||||
if (
|
||||
typeof params.modelId === "string" &&
|
||||
isGemini31Model(params.modelId) &&
|
||||
params.thinkingLevel &&
|
||||
params.thinkingLevel !== "off" &&
|
||||
thinkingConfigObj.thinkingLevel === undefined
|
||||
) {
|
||||
const mappedLevel = mapThinkLevelToGoogleThinkingLevel(params.thinkingLevel);
|
||||
if (mappedLevel) {
|
||||
thinkingConfigObj.thinkingLevel = mappedLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createGoogleThinkingPayloadWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
thinkingLevel?: ThinkLevel,
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
const onPayload = options?.onPayload;
|
||||
return underlying(model, context, {
|
||||
...options,
|
||||
onPayload: (payload) => {
|
||||
if (model.api === "google-generative-ai") {
|
||||
sanitizeGoogleThinkingPayload({
|
||||
payload,
|
||||
modelId: model.id,
|
||||
thinkingLevel,
|
||||
});
|
||||
}
|
||||
return onPayload?.(payload, model);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAliasedParamValue(
|
||||
sources: Array<Record<string, unknown> | undefined>,
|
||||
snakeCaseKey: string,
|
||||
@ -305,13 +211,6 @@ export function applyExtraParamsToAgent(
|
||||
},
|
||||
}) ?? merged;
|
||||
|
||||
if (provider === "openai" || provider === "openai-codex") {
|
||||
if (provider === "openai") {
|
||||
// Default OpenAI Responses to WebSocket-first with transparent SSE fallback.
|
||||
agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn);
|
||||
}
|
||||
agent.streamFn = createOpenAIAttributionHeadersWrapper(agent.streamFn);
|
||||
}
|
||||
const wrappedStreamFn = createStreamFnWithExtraParams(
|
||||
agent.streamFn,
|
||||
effectiveExtraParams,
|
||||
@ -370,25 +269,6 @@ export function applyExtraParamsToAgent(
|
||||
agent.streamFn = createMoonshotThinkingWrapper(agent.streamFn, thinkingType);
|
||||
}
|
||||
|
||||
if (provider === "amazon-bedrock" && !isAnthropicBedrockModel(modelId)) {
|
||||
log.debug(`disabling prompt caching for non-Anthropic Bedrock model ${provider}/${modelId}`);
|
||||
agent.streamFn = createBedrockNoCacheWrapper(agent.streamFn);
|
||||
}
|
||||
|
||||
// Enable Z.AI tool_stream for real-time tool call streaming.
|
||||
// Enabled by default for Z.AI provider, can be disabled via params.tool_stream: false
|
||||
if (provider === "zai" || provider === "z-ai") {
|
||||
const toolStreamEnabled = effectiveExtraParams?.tool_stream !== false;
|
||||
if (toolStreamEnabled) {
|
||||
log.debug(`enabling Z.AI tool_stream for ${provider}/${modelId}`);
|
||||
agent.streamFn = createZaiToolStreamWrapper(agent.streamFn, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Guard Google payloads against invalid negative thinking budgets emitted by
|
||||
// upstream model-ID heuristics for Gemini 3.1 variants.
|
||||
agent.streamFn = createGoogleThinkingPayloadWrapper(agent.streamFn, thinkingLevel);
|
||||
|
||||
const anthropicFastMode = resolveAnthropicFastMode(effectiveExtraParams);
|
||||
if (anthropicFastMode !== undefined) {
|
||||
log.debug(`applying Anthropic fast mode=${anthropicFastMode} for ${provider}/${modelId}`);
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runExtraParamsCase } from "./extra-params.test-support.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
streamSimple: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
result: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("extra-params: xAI tool payload compatibility", () => {
|
||||
it("strips function.strict for xai providers", () => {
|
||||
const payload = runExtraParamsCase({
|
||||
applyProvider: "xai",
|
||||
applyModelId: "grok-4-1-fast-reasoning",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "xai",
|
||||
id: "grok-4-1-fast-reasoning",
|
||||
} as Model<"openai-completions">,
|
||||
payload: {
|
||||
model: "grok-4-1-fast-reasoning",
|
||||
messages: [],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "write",
|
||||
description: "write a file",
|
||||
parameters: { type: "object", properties: {} },
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).payload as {
|
||||
tools?: Array<{ function?: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
expect(payload.tools?.[0]?.function).not.toHaveProperty("strict");
|
||||
});
|
||||
|
||||
it("keeps function.strict for non-xai providers", () => {
|
||||
const payload = runExtraParamsCase({
|
||||
applyProvider: "openai",
|
||||
applyModelId: "gpt-5.4",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
id: "gpt-5.4",
|
||||
} as Model<"openai-completions">,
|
||||
payload: {
|
||||
model: "gpt-5.4",
|
||||
messages: [],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "write",
|
||||
description: "write a file",
|
||||
parameters: { type: "object", properties: {} },
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).payload as {
|
||||
tools?: Array<{ function?: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
expect(payload.tools?.[0]?.function?.strict).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -30,7 +30,7 @@ function runToolStreamCase(params: ToolStreamCase) {
|
||||
}).payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("extra-params: Z.AI tool_stream support", () => {
|
||||
describe("extra-params: provider tool_stream support", () => {
|
||||
it("injects tool_stream=true for zai provider by default", () => {
|
||||
const payload = runToolStreamCase({
|
||||
applyProvider: "zai",
|
||||
@ -45,7 +45,21 @@ describe("extra-params: Z.AI tool_stream support", () => {
|
||||
expect(payload.tool_stream).toBe(true);
|
||||
});
|
||||
|
||||
it("does not inject tool_stream for non-zai providers", () => {
|
||||
it("injects tool_stream=true for xai provider by default", () => {
|
||||
const payload = runToolStreamCase({
|
||||
applyProvider: "xai",
|
||||
applyModelId: "grok-4-1-fast-reasoning",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "xai",
|
||||
id: "grok-4-1-fast-reasoning",
|
||||
} as Model<"openai-completions">,
|
||||
});
|
||||
|
||||
expect(payload.tool_stream).toBe(true);
|
||||
});
|
||||
|
||||
it("does not inject tool_stream for providers that do not need it", () => {
|
||||
const payload = runToolStreamCase({
|
||||
applyProvider: "openai",
|
||||
applyModelId: "gpt-5",
|
||||
@ -59,7 +73,7 @@ describe("extra-params: Z.AI tool_stream support", () => {
|
||||
expect(payload).not.toHaveProperty("tool_stream");
|
||||
});
|
||||
|
||||
it("allows disabling tool_stream via params", () => {
|
||||
it("allows disabling zai tool_stream via params", () => {
|
||||
const payload = runToolStreamCase({
|
||||
applyProvider: "zai",
|
||||
applyModelId: "glm-5",
|
||||
@ -85,4 +99,31 @@ describe("extra-params: Z.AI tool_stream support", () => {
|
||||
|
||||
expect(payload).not.toHaveProperty("tool_stream");
|
||||
});
|
||||
|
||||
it("allows disabling xai tool_stream via params", () => {
|
||||
const payload = runToolStreamCase({
|
||||
applyProvider: "xai",
|
||||
applyModelId: "grok-4-1-fast-reasoning",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "xai",
|
||||
id: "grok-4-1-fast-reasoning",
|
||||
} as Model<"openai-completions">,
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"xai/grok-4-1-fast-reasoning": {
|
||||
params: {
|
||||
tool_stream: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).not.toHaveProperty("tool_stream");
|
||||
});
|
||||
});
|
||||
|
||||
92
src/agents/pi-embedded-runner/google-stream-wrappers.ts
Normal file
92
src/agents/pi-embedded-runner/google-stream-wrappers.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
|
||||
function isGemini31Model(modelId: string): boolean {
|
||||
const normalized = modelId.toLowerCase();
|
||||
return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash");
|
||||
}
|
||||
|
||||
function mapThinkLevelToGoogleThinkingLevel(
|
||||
thinkingLevel: ThinkLevel,
|
||||
): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" | undefined {
|
||||
switch (thinkingLevel) {
|
||||
case "minimal":
|
||||
return "MINIMAL";
|
||||
case "low":
|
||||
return "LOW";
|
||||
case "medium":
|
||||
case "adaptive":
|
||||
return "MEDIUM";
|
||||
case "high":
|
||||
case "xhigh":
|
||||
return "HIGH";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeGoogleThinkingPayload(params: {
|
||||
payload: unknown;
|
||||
modelId?: string;
|
||||
thinkingLevel?: ThinkLevel;
|
||||
}): void {
|
||||
if (!params.payload || typeof params.payload !== "object") {
|
||||
return;
|
||||
}
|
||||
const payloadObj = params.payload as Record<string, unknown>;
|
||||
const config = payloadObj.config;
|
||||
if (!config || typeof config !== "object") {
|
||||
return;
|
||||
}
|
||||
const configObj = config as Record<string, unknown>;
|
||||
const thinkingConfig = configObj.thinkingConfig;
|
||||
if (!thinkingConfig || typeof thinkingConfig !== "object") {
|
||||
return;
|
||||
}
|
||||
const thinkingConfigObj = thinkingConfig as Record<string, unknown>;
|
||||
const thinkingBudget = thinkingConfigObj.thinkingBudget;
|
||||
if (typeof thinkingBudget !== "number" || thinkingBudget >= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// pi-ai can emit thinkingBudget=-1 for some Gemini 3.1 IDs; a negative budget
|
||||
// is invalid for Google-compatible backends and can lead to malformed handling.
|
||||
delete thinkingConfigObj.thinkingBudget;
|
||||
|
||||
if (
|
||||
typeof params.modelId === "string" &&
|
||||
isGemini31Model(params.modelId) &&
|
||||
params.thinkingLevel &&
|
||||
params.thinkingLevel !== "off" &&
|
||||
thinkingConfigObj.thinkingLevel === undefined
|
||||
) {
|
||||
const mappedLevel = mapThinkLevelToGoogleThinkingLevel(params.thinkingLevel);
|
||||
if (mappedLevel) {
|
||||
thinkingConfigObj.thinkingLevel = mappedLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createGoogleThinkingPayloadWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
thinkingLevel?: ThinkLevel,
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
const onPayload = options?.onPayload;
|
||||
return underlying(model, context, {
|
||||
...options,
|
||||
onPayload: (payload) => {
|
||||
if (model.api === "google-generative-ai") {
|
||||
sanitizeGoogleThinkingPayload({
|
||||
payload,
|
||||
modelId: model.id,
|
||||
thinkingLevel,
|
||||
});
|
||||
}
|
||||
return onPayload?.(payload, model);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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"]);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -1,61 +1,6 @@
|
||||
// xAI rejects these JSON Schema validation keywords in tool definitions instead of
|
||||
// ignoring them, causing 502 errors for any request that includes them. Strip them
|
||||
// before sending to xAI directly, or via OpenRouter when the downstream model is xAI.
|
||||
export const XAI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"minContains",
|
||||
"maxContains",
|
||||
]);
|
||||
import {
|
||||
stripXaiUnsupportedKeywords,
|
||||
XAI_UNSUPPORTED_SCHEMA_KEYWORDS,
|
||||
} from "../../plugin-sdk/provider-tools.js";
|
||||
|
||||
export function stripXaiUnsupportedKeywords(schema: unknown): unknown {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return schema;
|
||||
}
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map(stripXaiUnsupportedKeywords);
|
||||
}
|
||||
const obj = schema as Record<string, unknown>;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (XAI_UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
|
||||
cleaned[key] = Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([k, v]) => [
|
||||
k,
|
||||
stripXaiUnsupportedKeywords(v),
|
||||
]),
|
||||
);
|
||||
} else if (key === "items" && value && typeof value === "object") {
|
||||
cleaned[key] = Array.isArray(value)
|
||||
? value.map(stripXaiUnsupportedKeywords)
|
||||
: stripXaiUnsupportedKeywords(value);
|
||||
} else if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
|
||||
cleaned[key] = value.map(stripXaiUnsupportedKeywords);
|
||||
} else {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function isXaiProvider(modelProvider?: string, modelId?: string): boolean {
|
||||
const provider = modelProvider?.toLowerCase() ?? "";
|
||||
if (provider.includes("xai") || provider.includes("x-ai")) {
|
||||
return true;
|
||||
}
|
||||
const lowerModelId = modelId?.toLowerCase() ?? "";
|
||||
// OpenRouter proxies to xAI when the model id starts with "x-ai/"
|
||||
if (provider === "openrouter" && lowerModelId.startsWith("x-ai/")) {
|
||||
return true;
|
||||
}
|
||||
// Venice proxies to xAI/Grok models
|
||||
if (provider === "venice" && lowerModelId.includes("grok")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export { stripXaiUnsupportedKeywords, XAI_UNSUPPORTED_SCHEMA_KEYWORDS };
|
||||
|
||||
26
src/agents/tools/web-search-provider-credentials.ts
Normal file
26
src/agents/tools/web-search-provider-credentials.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
|
||||
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
|
||||
|
||||
export function resolveWebSearchProviderCredential(params: {
|
||||
credentialValue: unknown;
|
||||
path: string;
|
||||
envVars: string[];
|
||||
}): string | undefined {
|
||||
const fromConfigRaw = normalizeResolvedSecretInputString({
|
||||
value: params.credentialValue,
|
||||
path: params.path,
|
||||
});
|
||||
const fromConfig = normalizeSecretInput(fromConfigRaw);
|
||||
if (fromConfig) {
|
||||
return fromConfig;
|
||||
}
|
||||
|
||||
for (const envVar of params.envVars) {
|
||||
const fromEnv = normalizeSecretInput(process.env[envVar]);
|
||||
if (fromEnv) {
|
||||
return fromEnv;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@ -199,272 +199,119 @@ describe("web_search date normalization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search grok config resolution", () => {
|
||||
describe("web_search kimi config resolution", () => {
|
||||
it("uses config apiKey when provided", () => {
|
||||
expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key"); // pragma: allowlist secret
|
||||
expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key");
|
||||
});
|
||||
|
||||
it("returns undefined when no apiKey is available", () => {
|
||||
withEnv({ XAI_API_KEY: undefined }, () => {
|
||||
expect(resolveGrokApiKey({})).toBeUndefined();
|
||||
expect(resolveGrokApiKey(undefined)).toBeUndefined();
|
||||
it("falls back to env apiKey", () => {
|
||||
withEnv({ [kimiApiKeyEnv]: "kimi-env-key" }, () => {
|
||||
expect(resolveKimiApiKey({})).toBe("kimi-env-key");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default model when not specified", () => {
|
||||
expect(resolveGrokModel({})).toBe("grok-4-1-fast");
|
||||
expect(resolveGrokModel(undefined)).toBe("grok-4-1-fast");
|
||||
});
|
||||
|
||||
it("uses config model when provided", () => {
|
||||
expect(resolveGrokModel({ model: "grok-3" })).toBe("grok-3");
|
||||
expect(resolveKimiModel({ model: "moonshot-v1-32k" })).toBe("moonshot-v1-32k");
|
||||
});
|
||||
|
||||
it("defaults inlineCitations to false", () => {
|
||||
expect(resolveGrokInlineCitations({})).toBe(false);
|
||||
expect(resolveGrokInlineCitations(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("respects inlineCitations config", () => {
|
||||
expect(resolveGrokInlineCitations({ inlineCitations: true })).toBe(true);
|
||||
expect(resolveGrokInlineCitations({ inlineCitations: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search grok response parsing", () => {
|
||||
it("extracts content from Responses API message blocks", () => {
|
||||
const result = extractGrokContent({
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
content: [{ type: "output_text", text: "hello from output" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.text).toBe("hello from output");
|
||||
expect(result.annotationCitations).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts url_citation annotations from content blocks", () => {
|
||||
const result = extractGrokContent({
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "hello with citations",
|
||||
annotations: [
|
||||
{
|
||||
type: "url_citation",
|
||||
url: "https://example.com/a",
|
||||
start_index: 0,
|
||||
end_index: 5,
|
||||
},
|
||||
{
|
||||
type: "url_citation",
|
||||
url: "https://example.com/b",
|
||||
start_index: 6,
|
||||
end_index: 10,
|
||||
},
|
||||
{
|
||||
type: "url_citation",
|
||||
url: "https://example.com/a",
|
||||
start_index: 11,
|
||||
end_index: 15,
|
||||
}, // duplicate
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.text).toBe("hello with citations");
|
||||
expect(result.annotationCitations).toEqual(["https://example.com/a", "https://example.com/b"]);
|
||||
});
|
||||
|
||||
it("falls back to deprecated output_text", () => {
|
||||
const result = extractGrokContent({ output_text: "hello from output_text" });
|
||||
expect(result.text).toBe("hello from output_text");
|
||||
expect(result.annotationCitations).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns undefined text when no content found", () => {
|
||||
const result = extractGrokContent({});
|
||||
expect(result.text).toBeUndefined();
|
||||
expect(result.annotationCitations).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts output_text blocks directly in output array (no message wrapper)", () => {
|
||||
const result = extractGrokContent({
|
||||
output: [
|
||||
{ type: "web_search_call" },
|
||||
{
|
||||
type: "output_text",
|
||||
text: "direct output text",
|
||||
annotations: [
|
||||
{
|
||||
type: "url_citation",
|
||||
url: "https://example.com/direct",
|
||||
start_index: 0,
|
||||
end_index: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof extractGrokContent>[0]);
|
||||
expect(result.text).toBe("direct output text");
|
||||
expect(result.annotationCitations).toEqual(["https://example.com/direct"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search kimi config resolution", () => {
|
||||
it("uses config apiKey when provided", () => {
|
||||
expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key"); // pragma: allowlist secret
|
||||
});
|
||||
|
||||
it("falls back to KIMI_API_KEY, then MOONSHOT_API_KEY", () => {
|
||||
const kimiEnvValue = "kimi-env"; // pragma: allowlist secret
|
||||
const moonshotEnvValue = "moonshot-env"; // pragma: allowlist secret
|
||||
withEnv({ [kimiApiKeyEnv]: kimiEnvValue, [moonshotApiKeyEnv]: moonshotEnvValue }, () => {
|
||||
expect(resolveKimiApiKey({})).toBe(kimiEnvValue);
|
||||
});
|
||||
withEnv({ [kimiApiKeyEnv]: undefined, [moonshotApiKeyEnv]: moonshotEnvValue }, () => {
|
||||
expect(resolveKimiApiKey({})).toBe(moonshotEnvValue);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined when no Kimi key is configured", () => {
|
||||
withEnv({ KIMI_API_KEY: undefined, MOONSHOT_API_KEY: undefined }, () => {
|
||||
expect(resolveKimiApiKey({})).toBeUndefined();
|
||||
expect(resolveKimiApiKey(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves default model and baseUrl", () => {
|
||||
it("falls back to default model", () => {
|
||||
expect(resolveKimiModel({})).toBe("moonshot-v1-128k");
|
||||
});
|
||||
|
||||
it("uses config baseUrl when provided", () => {
|
||||
expect(resolveKimiBaseUrl({ baseUrl: "https://kimi.example/v1" })).toBe(
|
||||
"https://kimi.example/v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default baseUrl", () => {
|
||||
expect(resolveKimiBaseUrl({})).toBe("https://api.moonshot.ai/v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractKimiCitations", () => {
|
||||
it("collects unique URLs from search_results and tool arguments", () => {
|
||||
it("extracts citations from search_results", () => {
|
||||
expect(
|
||||
extractKimiCitations({
|
||||
search_results: [{ url: "https://example.com/a" }, { url: "https://example.com/a" }],
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
arguments: JSON.stringify({
|
||||
search_results: [{ url: "https://example.com/b" }],
|
||||
url: "https://example.com/c",
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
search_results: [
|
||||
{ url: "https://example.com/one" },
|
||||
{ url: "https://example.com/two" },
|
||||
],
|
||||
}).toSorted(),
|
||||
).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]);
|
||||
}),
|
||||
).toEqual(["https://example.com/one", "https://example.com/two"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBraveMode", () => {
|
||||
it("defaults to 'web' when no config is provided", () => {
|
||||
expect(resolveBraveMode({})).toBe("web");
|
||||
describe("web_search brave mode resolution", () => {
|
||||
it("defaults to web mode", () => {
|
||||
expect(resolveBraveMode(undefined)).toBe("web");
|
||||
});
|
||||
|
||||
it("defaults to 'web' when mode is undefined", () => {
|
||||
expect(resolveBraveMode({ mode: undefined })).toBe("web");
|
||||
});
|
||||
|
||||
it("returns 'llm-context' when configured", () => {
|
||||
it("honors explicit llm-context mode", () => {
|
||||
expect(resolveBraveMode({ mode: "llm-context" })).toBe("llm-context");
|
||||
});
|
||||
|
||||
it("returns 'web' when mode is explicitly 'web'", () => {
|
||||
expect(resolveBraveMode({ mode: "web" })).toBe("web");
|
||||
});
|
||||
|
||||
it("falls back to 'web' for unrecognized mode values", () => {
|
||||
expect(resolveBraveMode({ mode: "invalid" })).toBe("web");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapBraveLlmContextResults", () => {
|
||||
it("maps plain string snippets correctly", () => {
|
||||
const results = mapBraveLlmContextResults({
|
||||
grounding: {
|
||||
generic: [
|
||||
{
|
||||
url: "https://example.com/page",
|
||||
title: "Example Page",
|
||||
snippets: ["first snippet", "second snippet"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(results).toEqual([
|
||||
it("maps llm context results", () => {
|
||||
expect(
|
||||
mapBraveLlmContextResults({
|
||||
grounding: {
|
||||
generic: [{ url: "https://example.com", title: "Example", snippets: ["A", "B"] }],
|
||||
},
|
||||
sources: [{ url: "https://example.com", hostname: "example.com", date: "2024-01-01" }],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
url: "https://example.com/page",
|
||||
title: "Example Page",
|
||||
snippets: ["first snippet", "second snippet"],
|
||||
siteName: "example.com",
|
||||
title: "Example",
|
||||
url: "https://example.com",
|
||||
description: "A B",
|
||||
age: "2024-01-01",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("filters out non-string and empty snippets", () => {
|
||||
const results = mapBraveLlmContextResults({
|
||||
grounding: {
|
||||
generic: [
|
||||
describe("web_search grok config resolution", () => {
|
||||
it("uses config apiKey when provided", () => {
|
||||
expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key");
|
||||
});
|
||||
|
||||
it("falls back to env apiKey", () => {
|
||||
withEnv({ XAI_API_KEY: "xai-env-key" }, () => {
|
||||
expect(resolveGrokApiKey({})).toBe("xai-env-key");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses config model when provided", () => {
|
||||
expect(resolveGrokModel({ model: "grok-4-fast" })).toBe("grok-4-fast");
|
||||
});
|
||||
|
||||
it("falls back to default model", () => {
|
||||
expect(resolveGrokModel({})).toBe("grok-4-1-fast");
|
||||
});
|
||||
|
||||
it("resolves inline citations flag", () => {
|
||||
expect(resolveGrokInlineCitations({ inlineCitations: true })).toBe(true);
|
||||
expect(resolveGrokInlineCitations({ inlineCitations: false })).toBe(false);
|
||||
expect(resolveGrokInlineCitations({})).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts content and annotation citations", () => {
|
||||
expect(
|
||||
extractGrokContent({
|
||||
output: [
|
||||
{
|
||||
url: "https://example.com",
|
||||
title: "Test",
|
||||
snippets: ["valid", "", null, undefined, 42, { text: "object" }] as string[],
|
||||
type: "message",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "Result",
|
||||
annotations: [{ type: "url_citation", url: "https://example.com" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
text: "Result",
|
||||
annotationCitations: ["https://example.com"],
|
||||
});
|
||||
expect(results[0].snippets).toEqual(["valid"]);
|
||||
});
|
||||
|
||||
it("handles missing snippets array", () => {
|
||||
const results = mapBraveLlmContextResults({
|
||||
grounding: {
|
||||
generic: [{ url: "https://example.com", title: "No Snippets" } as never],
|
||||
},
|
||||
});
|
||||
expect(results[0].snippets).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles empty grounding.generic", () => {
|
||||
expect(mapBraveLlmContextResults({ grounding: { generic: [] } })).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles missing grounding.generic", () => {
|
||||
expect(mapBraveLlmContextResults({ grounding: {} } as never)).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves siteName from URL hostname", () => {
|
||||
const results = mapBraveLlmContextResults({
|
||||
grounding: {
|
||||
generic: [{ url: "https://docs.example.org/path", title: "Docs", snippets: ["text"] }],
|
||||
},
|
||||
});
|
||||
expect(results[0].siteName).toBe("docs.example.org");
|
||||
});
|
||||
|
||||
it("sets siteName to undefined for invalid URLs", () => {
|
||||
const results = mapBraveLlmContextResults({
|
||||
grounding: {
|
||||
generic: [{ url: "not-a-url", title: "Bad URL", snippets: ["text"] }],
|
||||
},
|
||||
});
|
||||
expect(results[0].siteName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,123 +1,36 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { resolvePluginWebSearchProviders } from "../../plugins/web-search-providers.js";
|
||||
import type { PluginWebSearchProviderEntry } from "../../plugins/types.js";
|
||||
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
|
||||
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
|
||||
import {
|
||||
__testing as runtimeTesting,
|
||||
resolveWebSearchDefinition,
|
||||
} from "../../web-search/runtime.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult } from "./common.js";
|
||||
import { SEARCH_CACHE } from "./web-search-provider-common.js";
|
||||
import {
|
||||
resolveSearchConfig,
|
||||
resolveSearchEnabled,
|
||||
type WebSearchConfig,
|
||||
} from "./web-search-provider-config.js";
|
||||
|
||||
function readProviderEnvValue(envVars: string[]): string | undefined {
|
||||
for (const envVar of envVars) {
|
||||
const value = normalizeSecretInput(process.env[envVar]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasProviderCredential(
|
||||
provider: PluginWebSearchProviderEntry,
|
||||
search: WebSearchConfig | undefined,
|
||||
): boolean {
|
||||
const rawValue = provider.getCredentialValue(search as Record<string, unknown> | undefined);
|
||||
const fromConfig = normalizeSecretInput(
|
||||
normalizeResolvedSecretInputString({
|
||||
value: rawValue,
|
||||
path: provider.credentialPath,
|
||||
}),
|
||||
);
|
||||
return Boolean(fromConfig || readProviderEnvValue(provider.envVars));
|
||||
}
|
||||
|
||||
function resolveSearchProvider(search?: WebSearchConfig): string {
|
||||
const providers = resolvePluginWebSearchProviders({
|
||||
bundledAllowlistCompat: true,
|
||||
});
|
||||
const raw =
|
||||
search && "provider" in search && typeof search.provider === "string"
|
||||
? search.provider.trim().toLowerCase()
|
||||
: "";
|
||||
|
||||
if (raw) {
|
||||
const explicit = providers.find((provider) => provider.id === raw);
|
||||
if (explicit) {
|
||||
return explicit.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
for (const provider of providers) {
|
||||
if (!hasProviderCredential(provider, search)) {
|
||||
continue;
|
||||
}
|
||||
logVerbose(
|
||||
`web_search: no provider configured, auto-detected "${provider.id}" from available API keys`,
|
||||
);
|
||||
return provider.id;
|
||||
}
|
||||
}
|
||||
|
||||
return providers[0]?.id ?? "";
|
||||
}
|
||||
|
||||
export function createWebSearchTool(options?: {
|
||||
config?: OpenClawConfig;
|
||||
sandboxed?: boolean;
|
||||
runtimeWebSearch?: RuntimeWebSearchMetadata;
|
||||
}): AnyAgentTool | null {
|
||||
const search = resolveSearchConfig(options?.config);
|
||||
if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providers = resolvePluginWebSearchProviders({
|
||||
const resolved = resolveWebSearchDefinition({
|
||||
config: options?.config,
|
||||
bundledAllowlistCompat: true,
|
||||
sandboxed: options?.sandboxed,
|
||||
runtimeWebSearch: options?.runtimeWebSearch,
|
||||
});
|
||||
if (providers.length === 0) {
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerId =
|
||||
options?.runtimeWebSearch?.selectedProvider ??
|
||||
options?.runtimeWebSearch?.providerConfigured ??
|
||||
resolveSearchProvider(search);
|
||||
const provider =
|
||||
providers.find((entry) => entry.id === providerId) ??
|
||||
providers.find((entry) => entry.id === resolveSearchProvider(search)) ??
|
||||
providers[0];
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = provider.createTool({
|
||||
config: options?.config,
|
||||
searchConfig: search as Record<string, unknown> | undefined,
|
||||
runtimeMetadata: options?.runtimeWebSearch,
|
||||
});
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: "Web Search",
|
||||
name: "web_search",
|
||||
description: definition.description,
|
||||
parameters: definition.parameters,
|
||||
execute: async (_toolCallId, args) => jsonResult(await definition.execute(args)),
|
||||
description: resolved.definition.description,
|
||||
parameters: resolved.definition.parameters,
|
||||
execute: async (_toolCallId, args) => jsonResult(await resolved.definition.execute(args)),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
SEARCH_CACHE,
|
||||
resolveSearchProvider,
|
||||
...runtimeTesting,
|
||||
};
|
||||
|
||||
169
src/agents/xai.live.test.ts
Normal file
169
src/agents/xai.live.test.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import { completeSimple, getModel, streamSimple } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import {
|
||||
createSingleUserPromptMessage,
|
||||
extractNonEmptyAssistantText,
|
||||
} from "./live-test-helpers.js";
|
||||
import { applyExtraParamsToAgent } from "./pi-embedded-runner.js";
|
||||
import { createWebSearchTool } from "./tools/web-search.js";
|
||||
|
||||
const XAI_KEY = process.env.XAI_API_KEY ?? "";
|
||||
const LIVE = isTruthyEnvValue(process.env.XAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
|
||||
const describeLive = LIVE && XAI_KEY ? describe : describe.skip;
|
||||
|
||||
type AssistantLikeMessage = {
|
||||
content: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
id?: string;
|
||||
function?: {
|
||||
strict?: unknown;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
function resolveLiveXaiModel() {
|
||||
return getModel("xai", "grok-4-1-fast-reasoning") ?? getModel("xai", "grok-4");
|
||||
}
|
||||
|
||||
async function collectDoneMessage(
|
||||
stream: AsyncIterable<{ type: string; message?: AssistantLikeMessage }>,
|
||||
): Promise<AssistantLikeMessage> {
|
||||
let doneMessage: AssistantLikeMessage | undefined;
|
||||
for await (const event of stream) {
|
||||
if (event.type === "done") {
|
||||
doneMessage = event.message;
|
||||
}
|
||||
}
|
||||
expect(doneMessage).toBeDefined();
|
||||
return doneMessage!;
|
||||
}
|
||||
|
||||
function extractFirstToolCallId(message: AssistantLikeMessage): string | undefined {
|
||||
const toolCall = message.content.find((block) => block.type === "toolCall");
|
||||
return toolCall?.id;
|
||||
}
|
||||
|
||||
describeLive("xai live", () => {
|
||||
it("returns assistant text for Grok 4.1 Fast Reasoning", async () => {
|
||||
const model = resolveLiveXaiModel();
|
||||
expect(model).toBeDefined();
|
||||
const res = await completeSimple(
|
||||
model,
|
||||
{
|
||||
messages: createSingleUserPromptMessage(),
|
||||
},
|
||||
{
|
||||
apiKey: XAI_KEY,
|
||||
maxTokens: 64,
|
||||
reasoning: "medium",
|
||||
},
|
||||
);
|
||||
|
||||
expect(extractNonEmptyAssistantText(res.content).length).toBeGreaterThan(0);
|
||||
}, 30_000);
|
||||
|
||||
it("applies xAI tool wrappers on live tool calls", async () => {
|
||||
const model = resolveLiveXaiModel();
|
||||
expect(model).toBeDefined();
|
||||
const agent = { streamFn: streamSimple };
|
||||
applyExtraParamsToAgent(agent, undefined, "xai", model.id);
|
||||
|
||||
const noopTool = {
|
||||
name: "noop",
|
||||
description: "Return ok.",
|
||||
parameters: Type.Object({}, { additionalProperties: false }),
|
||||
};
|
||||
|
||||
const prompts = [
|
||||
"Call the tool `noop` with {}. Do not write any other text.",
|
||||
"IMPORTANT: Call the tool `noop` with {} and respond only with the tool call.",
|
||||
"Return only a tool call for `noop` with {}.",
|
||||
];
|
||||
|
||||
let doneMessage: AssistantLikeMessage | undefined;
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
|
||||
for (const prompt of prompts) {
|
||||
capturedPayload = undefined;
|
||||
const stream = agent.streamFn(
|
||||
model,
|
||||
{
|
||||
messages: createSingleUserPromptMessage(prompt),
|
||||
tools: [noopTool],
|
||||
},
|
||||
{
|
||||
apiKey: XAI_KEY,
|
||||
maxTokens: 128,
|
||||
reasoning: "medium",
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload as Record<string, unknown>;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
doneMessage = await collectDoneMessage(
|
||||
stream as AsyncIterable<{ type: string; message?: AssistantLikeMessage }>,
|
||||
);
|
||||
if (extractFirstToolCallId(doneMessage)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(doneMessage).toBeDefined();
|
||||
expect(extractFirstToolCallId(doneMessage!)).toBeDefined();
|
||||
expect(capturedPayload?.tool_stream).toBe(true);
|
||||
|
||||
const payloadTools = Array.isArray(capturedPayload?.tools)
|
||||
? (capturedPayload.tools as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const firstFunction = payloadTools[0]?.function;
|
||||
if (firstFunction && typeof firstFunction === "object") {
|
||||
expect((firstFunction as Record<string, unknown>).strict).toBeUndefined();
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it("runs Grok web_search live", async () => {
|
||||
const tool = createWebSearchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "grok",
|
||||
grok: {
|
||||
model: "grok-4-1-fast",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(tool).toBeTruthy();
|
||||
const result = await tool!.execute("web-search:grok-live", {
|
||||
query: "OpenClaw GitHub",
|
||||
count: 3,
|
||||
});
|
||||
|
||||
const details = (result.details ?? {}) as {
|
||||
provider?: string;
|
||||
content?: string;
|
||||
citations?: string[];
|
||||
inlineCitations?: Array<unknown>;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
expect(details.error, details.message).toBeUndefined();
|
||||
expect(details.provider).toBe("grok");
|
||||
expect(details.content?.trim().length ?? 0).toBeGreaterThan(0);
|
||||
|
||||
const citationCount =
|
||||
(Array.isArray(details.citations) ? details.citations.length : 0) +
|
||||
(Array.isArray(details.inlineCitations) ? details.inlineCitations.length : 0);
|
||||
expect(citationCount).toBeGreaterThan(0);
|
||||
}, 45_000);
|
||||
});
|
||||
@ -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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
56
src/plugin-sdk/provider-tools.ts
Normal file
56
src/plugin-sdk/provider-tools.ts
Normal file
@ -0,0 +1,56 @@
|
||||
// Shared provider-tool helpers for plugin-owned schema compatibility rewrites.
|
||||
|
||||
export const XAI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"minContains",
|
||||
"maxContains",
|
||||
]);
|
||||
|
||||
export function stripUnsupportedSchemaKeywords(
|
||||
schema: unknown,
|
||||
unsupportedKeywords: ReadonlySet<string>,
|
||||
): unknown {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return schema;
|
||||
}
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map((entry) => stripUnsupportedSchemaKeywords(entry, unsupportedKeywords));
|
||||
}
|
||||
const obj = schema as Record<string, unknown>;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (unsupportedKeywords.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
|
||||
cleaned[key] = Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([childKey, childValue]) => [
|
||||
childKey,
|
||||
stripUnsupportedSchemaKeywords(childValue, unsupportedKeywords),
|
||||
]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (key === "items" && value && typeof value === "object") {
|
||||
cleaned[key] = Array.isArray(value)
|
||||
? value.map((entry) => stripUnsupportedSchemaKeywords(entry, unsupportedKeywords))
|
||||
: stripUnsupportedSchemaKeywords(value, unsupportedKeywords);
|
||||
continue;
|
||||
}
|
||||
if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
|
||||
cleaned[key] = value.map((entry) =>
|
||||
stripUnsupportedSchemaKeywords(entry, unsupportedKeywords),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
cleaned[key] = value;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function stripXaiUnsupportedKeywords(schema: unknown): unknown {
|
||||
return stripUnsupportedSchemaKeywords(schema, XAI_UNSUPPORTED_SCHEMA_KEYWORDS);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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")),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user