mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 13:20:21 +00:00
feat: finish xai provider integration
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user