mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 02:41:07 +00:00
refactor(xai): move bundled xai runtime into plugin
Co-authored-by: Harold Hunt <harold@pwrdrvr.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { applyXaiModelCompat, buildXaiProvider } from "./api.js";
|
||||
import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
|
||||
import {
|
||||
createXaiFastModeWrapper,
|
||||
createXaiToolCallArgumentDecodingWrapper,
|
||||
createXaiToolPayloadCompatibilityWrapper,
|
||||
} from "./stream.js";
|
||||
@@ -47,13 +48,14 @@ export default defineSingleProviderPluginEntry({
|
||||
tool_stream: true,
|
||||
};
|
||||
},
|
||||
wrapStreamFn: (ctx) =>
|
||||
createToolStreamWrapper(
|
||||
createXaiToolCallArgumentDecodingWrapper(
|
||||
createXaiToolPayloadCompatibilityWrapper(ctx.streamFn),
|
||||
),
|
||||
ctx.extraParams?.tool_stream !== false,
|
||||
),
|
||||
wrapStreamFn: (ctx) => {
|
||||
let streamFn = createXaiToolPayloadCompatibilityWrapper(ctx.streamFn);
|
||||
if (typeof ctx.extraParams?.fastMode === "boolean") {
|
||||
streamFn = createXaiFastModeWrapper(streamFn, ctx.extraParams.fastMode);
|
||||
}
|
||||
streamFn = createXaiToolCallArgumentDecodingWrapper(streamFn);
|
||||
return createToolStreamWrapper(streamFn, ctx.extraParams?.tool_stream !== false);
|
||||
},
|
||||
normalizeResolvedModel: ({ model }) => applyXaiModelCompat(model),
|
||||
resolveDynamicModel: (ctx) => resolveXaiForwardCompatModel({ providerId: PROVIDER_ID, ctx }),
|
||||
isModernModelRef: ({ modelId }) => isModernXaiModel(modelId),
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnv } from "../../../test/helpers/extensions/env.js";
|
||||
import { __testing } from "./grok-web-search-provider.js";
|
||||
|
||||
describe("grok web search provider", () => {
|
||||
it("uses config apiKey when provided", () => {
|
||||
expect(__testing.resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key");
|
||||
});
|
||||
|
||||
it("falls back to env apiKey", () => {
|
||||
withEnv({ XAI_API_KEY: "xai-env-key" }, () => {
|
||||
expect(__testing.resolveGrokApiKey({})).toBe("xai-env-key");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses config model when provided", () => {
|
||||
expect(__testing.resolveGrokModel({ model: "grok-4-fast" })).toBe("grok-4-fast");
|
||||
});
|
||||
|
||||
it("normalizes deprecated grok 4.20 beta ids to GA ids", () => {
|
||||
expect(
|
||||
__testing.resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-reasoning" }),
|
||||
).toBe("grok-4.20-beta-latest-reasoning");
|
||||
expect(
|
||||
__testing.resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-non-reasoning" }),
|
||||
).toBe("grok-4.20-beta-latest-non-reasoning");
|
||||
});
|
||||
|
||||
it("falls back to default model", () => {
|
||||
expect(__testing.resolveGrokModel({})).toBe("grok-4-1-fast");
|
||||
});
|
||||
|
||||
it("resolves inline citations flag", () => {
|
||||
expect(__testing.resolveGrokInlineCitations({ inlineCitations: true })).toBe(true);
|
||||
expect(__testing.resolveGrokInlineCitations({ inlineCitations: false })).toBe(false);
|
||||
expect(__testing.resolveGrokInlineCitations({})).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts content and annotation citations", () => {
|
||||
expect(
|
||||
__testing.extractGrokContent({
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "Result",
|
||||
annotations: [{ type: "url_citation", url: "https://example.com" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
text: "Result",
|
||||
annotationCitations: ["https://example.com"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
buildUnsupportedSearchFilterResponse,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
getScopedCredentialValue,
|
||||
MAX_SEARCH_COUNT,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readNumberParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
mergeScopedSearchConfig,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
setScopedCredentialValue,
|
||||
setProviderWebSearchPluginConfigValue,
|
||||
type SearchConfigRecord,
|
||||
type WebSearchProviderPlugin,
|
||||
type WebSearchProviderToolDefinition,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import {
|
||||
buildXaiWebSearchPayload,
|
||||
extractXaiWebSearchContent,
|
||||
requestXaiWebSearch,
|
||||
resolveXaiInlineCitations,
|
||||
resolveXaiSearchConfig,
|
||||
resolveXaiWebSearchModel,
|
||||
} from "./web-search-shared.js";
|
||||
|
||||
function resolveGrokApiKey(grok?: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(grok?.apiKey, "tools.web.search.grok.apiKey") ??
|
||||
readProviderEnvValue(["XAI_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function createGrokSchema() {
|
||||
return 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: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
country: Type.Optional(Type.String({ description: "Not supported by Grok." })),
|
||||
language: Type.Optional(Type.String({ description: "Not supported by Grok." })),
|
||||
freshness: Type.Optional(Type.String({ description: "Not supported by Grok." })),
|
||||
date_after: Type.Optional(Type.String({ description: "Not supported by Grok." })),
|
||||
date_before: Type.Optional(Type.String({ description: "Not supported by Grok." })),
|
||||
});
|
||||
}
|
||||
|
||||
function createGrokToolDefinition(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
): WebSearchProviderToolDefinition {
|
||||
return {
|
||||
description:
|
||||
"Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.",
|
||||
parameters: createGrokSchema(),
|
||||
execute: async (args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "grok");
|
||||
if (unsupportedResponse) {
|
||||
return unsupportedResponse;
|
||||
}
|
||||
|
||||
const grokConfig = resolveXaiSearchConfig(searchConfig);
|
||||
const apiKey = resolveGrokApiKey(grokConfig);
|
||||
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 = readStringParam(params, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
const model = resolveXaiWebSearchModel(searchConfig);
|
||||
const inlineCitations = resolveXaiInlineCitations(searchConfig);
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"grok",
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
model,
|
||||
inlineCitations,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const result = await requestXaiWebSearch({
|
||||
query,
|
||||
apiKey,
|
||||
model,
|
||||
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
||||
inlineCitations,
|
||||
});
|
||||
const payload = buildXaiWebSearchPayload({
|
||||
query,
|
||||
provider: "grok",
|
||||
model,
|
||||
tookMs: Date.now() - start,
|
||||
content: result.content,
|
||||
citations: result.citations,
|
||||
inlineCitations: result.inlineCitations,
|
||||
});
|
||||
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createGrokWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
id: "grok",
|
||||
label: "Grok (xAI)",
|
||||
hint: "Requires xAI API key · xAI web-grounded responses",
|
||||
credentialLabel: "xAI API key",
|
||||
envVars: ["XAI_API_KEY"],
|
||||
placeholder: "xai-...",
|
||||
signupUrl: "https://console.x.ai/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 30,
|
||||
credentialPath: "plugins.entries.xai.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.xai.config.webSearch.apiKey"],
|
||||
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "grok"),
|
||||
setCredentialValue: (searchConfigTarget, value) =>
|
||||
setScopedCredentialValue(searchConfigTarget, "grok", value),
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
resolveProviderWebSearchPluginConfig(config, "xai")?.apiKey,
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
setProviderWebSearchPluginConfigValue(configTarget, "xai", "apiKey", value);
|
||||
},
|
||||
createTool: (ctx) =>
|
||||
createGrokToolDefinition(
|
||||
mergeScopedSearchConfig(
|
||||
ctx.searchConfig as SearchConfigRecord | undefined,
|
||||
"grok",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "xai"),
|
||||
) as SearchConfigRecord | undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveGrokApiKey,
|
||||
resolveGrokModel: (grok?: Record<string, unknown>) =>
|
||||
resolveXaiWebSearchModel(grok ? { grok } : undefined),
|
||||
resolveGrokInlineCitations: (grok?: Record<string, unknown>) =>
|
||||
resolveXaiInlineCitations(grok ? { grok } : undefined),
|
||||
extractGrokContent: extractXaiWebSearchContent,
|
||||
extractXaiWebSearchContent,
|
||||
resolveXaiInlineCitations,
|
||||
resolveXaiSearchConfig,
|
||||
resolveXaiWebSearchModel,
|
||||
requestXaiWebSearch,
|
||||
buildXaiWebSearchPayload,
|
||||
} as const;
|
||||
69
extensions/xai/stream.test.ts
Normal file
69
extensions/xai/stream.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Context, Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createXaiFastModeWrapper, createXaiToolPayloadCompatibilityWrapper } from "./stream.js";
|
||||
|
||||
function captureWrappedModelId(params: { modelId: string; fastMode: boolean }): string {
|
||||
let capturedModelId = "";
|
||||
const baseStreamFn: StreamFn = (model) => {
|
||||
capturedModelId = model.id;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrapped = createXaiFastModeWrapper(baseStreamFn, params.fastMode);
|
||||
void wrapped(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "xai",
|
||||
id: params.modelId,
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
return capturedModelId;
|
||||
}
|
||||
|
||||
describe("xai stream wrappers", () => {
|
||||
it("rewrites supported Grok models to fast variants when fast mode is enabled", () => {
|
||||
expect(captureWrappedModelId({ modelId: "grok-3", fastMode: true })).toBe("grok-3-fast");
|
||||
expect(captureWrappedModelId({ modelId: "grok-4", fastMode: true })).toBe("grok-4-fast");
|
||||
});
|
||||
|
||||
it("leaves unsupported or disabled models unchanged", () => {
|
||||
expect(captureWrappedModelId({ modelId: "grok-3-fast", fastMode: true })).toBe("grok-3-fast");
|
||||
expect(captureWrappedModelId({ modelId: "grok-3", fastMode: false })).toBe("grok-3");
|
||||
});
|
||||
|
||||
it("strips function.strict from tool payloads", () => {
|
||||
const payload = {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "write",
|
||||
parameters: { type: "object", properties: {} },
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
options?.onPayload?.(payload, {} as Model<"openai-completions">);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "xai",
|
||||
id: "grok-4-1-fast-reasoning",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(payload.tools[0]?.function).not.toHaveProperty("strict");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,20 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
|
||||
const XAI_FAST_MODEL_IDS = new Map<string, string>([
|
||||
["grok-3", "grok-3-fast"],
|
||||
["grok-3-mini", "grok-3-mini-fast"],
|
||||
["grok-4", "grok-4-fast"],
|
||||
["grok-4-0709", "grok-4-fast"],
|
||||
]);
|
||||
|
||||
function resolveXaiFastModelId(modelId: unknown): string | undefined {
|
||||
if (typeof modelId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return XAI_FAST_MODEL_IDS.get(modelId.trim());
|
||||
}
|
||||
|
||||
function stripUnsupportedStrictFlag(tool: unknown): unknown {
|
||||
if (!tool || typeof tool !== "object") {
|
||||
return tool;
|
||||
@@ -40,6 +54,25 @@ export function createXaiToolPayloadCompatibilityWrapper(
|
||||
};
|
||||
}
|
||||
|
||||
export function createXaiFastModeWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
fastMode: boolean,
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
if (!fastMode || model.api !== "openai-completions" || model.provider !== "xai") {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
const fastModelId = resolveXaiFastModelId(model.id);
|
||||
if (!fastModelId) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
return underlying({ ...model, id: fastModelId }, context, options);
|
||||
};
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value
|
||||
.replaceAll(""", '"')
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { __testing } from "./src/grok-web-search-provider.js";
|
||||
@@ -1,44 +1,109 @@
|
||||
import {
|
||||
getScopedCredentialValue,
|
||||
resolveWebSearchProviderCredential,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnv } from "../../test/helpers/extensions/env.js";
|
||||
import { resolveXaiCatalogEntry } from "./model-definitions.js";
|
||||
import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
|
||||
import { __testing as grokProviderTesting } from "./src/grok-web-search-provider.js";
|
||||
import { __testing } from "./web-search.js";
|
||||
import { __testing, createXaiWebSearchProvider } from "./web-search.js";
|
||||
|
||||
const { extractXaiWebSearchContent, resolveXaiInlineCitations, resolveXaiWebSearchModel } =
|
||||
__testing;
|
||||
const {
|
||||
extractXaiWebSearchContent,
|
||||
resolveXaiInlineCitations,
|
||||
resolveXaiToolSearchConfig,
|
||||
resolveXaiWebSearchCredential,
|
||||
resolveXaiWebSearchModel,
|
||||
} = __testing;
|
||||
|
||||
describe("xai web search config resolution", () => {
|
||||
it("prefers configured api keys and resolves grok scoped defaults", () => {
|
||||
expect(grokProviderTesting.resolveGrokApiKey({ apiKey: "xai-secret" })).toBe("xai-secret");
|
||||
expect(grokProviderTesting.resolveGrokModel()).toBe("grok-4-1-fast");
|
||||
expect(grokProviderTesting.resolveGrokInlineCitations()).toBe(false);
|
||||
expect(resolveXaiWebSearchCredential({ grok: { apiKey: "xai-secret" } })).toBe("xai-secret");
|
||||
expect(resolveXaiWebSearchModel()).toBe("grok-4-1-fast");
|
||||
expect(resolveXaiInlineCitations()).toBe(false);
|
||||
});
|
||||
|
||||
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");
|
||||
expect(resolveXaiWebSearchCredential({ grok: { apiKey: "xai-test-key" } })).toBe(
|
||||
"xai-test-key",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined when no apiKey is available", () => {
|
||||
withEnv({ XAI_API_KEY: undefined }, () => {
|
||||
expect(resolveXaiWebSearchCredential({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves env SecretRefs without requiring a runtime snapshot", () => {
|
||||
withEnv({ XAI_WEB_SEARCH_KEY: "xai-env-ref-key" }, () => {
|
||||
expect(
|
||||
resolveWebSearchProviderCredential({
|
||||
credentialValue: getScopedCredentialValue({}, "grok"),
|
||||
path: "tools.web.search.grok.apiKey",
|
||||
envVars: ["XAI_API_KEY"],
|
||||
resolveXaiWebSearchCredential({
|
||||
grok: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "XAI_WEB_SEARCH_KEY",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
).toBe("xai-env-ref-key");
|
||||
});
|
||||
});
|
||||
|
||||
it("merges canonical plugin config into the tool search config", () => {
|
||||
const searchConfig = resolveXaiToolSearchConfig({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "plugin-key",
|
||||
inlineCitations: true,
|
||||
model: "grok-4-fast-reasoning",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "grok" },
|
||||
});
|
||||
|
||||
expect(resolveXaiWebSearchCredential(searchConfig)).toBe("plugin-key");
|
||||
expect(resolveXaiInlineCitations(searchConfig)).toBe(true);
|
||||
expect(resolveXaiWebSearchModel(searchConfig)).toBe("grok-4-fast");
|
||||
});
|
||||
|
||||
it("treats unresolved non-env SecretRefs as missing credentials instead of throwing", async () => {
|
||||
await withEnv({ XAI_API_KEY: undefined }, async () => {
|
||||
const provider = createXaiWebSearchProvider();
|
||||
const maybeTool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "file",
|
||||
provider: "vault",
|
||||
id: "/providers/xai/web-search",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(maybeTool).toBeTruthy();
|
||||
if (!maybeTool) {
|
||||
throw new Error("expected xai web search tool");
|
||||
}
|
||||
|
||||
await expect(maybeTool.execute({ query: "OpenClaw" })).resolves.toMatchObject({
|
||||
error: "missing_xai_api_key",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,7 +143,7 @@ describe("xai web search config resolution", () => {
|
||||
|
||||
it("builds wrapped payloads with optional inline citations", () => {
|
||||
expect(
|
||||
grokProviderTesting.buildXaiWebSearchPayload({
|
||||
__testing.buildXaiWebSearchPayload({
|
||||
query: "q",
|
||||
provider: "grok",
|
||||
model: "grok-4-fast",
|
||||
|
||||
@@ -3,14 +3,18 @@ import {
|
||||
DEFAULT_CACHE_TTL_MINUTES,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
getScopedCredentialValue,
|
||||
mergeScopedSearchConfig,
|
||||
normalizeCacheKey,
|
||||
readCache,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
resolveCacheTtlMs,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveTimeoutSeconds,
|
||||
resolveWebSearchProviderCredential,
|
||||
setProviderWebSearchPluginConfigValue,
|
||||
setScopedCredentialValue,
|
||||
type SearchConfigRecord,
|
||||
type WebSearchProviderPlugin,
|
||||
writeCache,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
@@ -67,6 +71,25 @@ function runXaiWebSearch(params: {
|
||||
})();
|
||||
}
|
||||
|
||||
function resolveXaiToolSearchConfig(ctx: {
|
||||
config?: Record<string, unknown>;
|
||||
searchConfig?: Record<string, unknown>;
|
||||
}): SearchConfigRecord | undefined {
|
||||
return mergeScopedSearchConfig(
|
||||
ctx.searchConfig as SearchConfigRecord | undefined,
|
||||
"grok",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "xai"),
|
||||
) as SearchConfigRecord | undefined;
|
||||
}
|
||||
|
||||
function resolveXaiWebSearchCredential(searchConfig?: SearchConfigRecord): string | undefined {
|
||||
return resolveWebSearchProviderCredential({
|
||||
credentialValue: getScopedCredentialValue(searchConfig, "grok"),
|
||||
path: "tools.web.search.grok.apiKey",
|
||||
envVars: ["XAI_API_KEY"],
|
||||
});
|
||||
}
|
||||
|
||||
export function createXaiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
id: "grok",
|
||||
@@ -85,61 +108,67 @@ export function createXaiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
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 plugins.entries.xai.config.webSearch.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const query = readStringParam(args, "query", { required: true });
|
||||
void readNumberParam(args, "count", { integer: true });
|
||||
|
||||
return await runXaiWebSearch({
|
||||
query,
|
||||
model: resolveXaiWebSearchModel(ctx.searchConfig),
|
||||
apiKey,
|
||||
timeoutSeconds: resolveTimeoutSeconds(
|
||||
(ctx.searchConfig?.timeoutSeconds as number | undefined) ?? undefined,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
resolveProviderWebSearchPluginConfig(config, "xai")?.apiKey,
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
setProviderWebSearchPluginConfigValue(configTarget, "xai", "apiKey", value);
|
||||
},
|
||||
createTool: (ctx) => {
|
||||
const searchConfig = resolveXaiToolSearchConfig(ctx);
|
||||
return {
|
||||
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,
|
||||
}),
|
||||
),
|
||||
inlineCitations: resolveXaiInlineCitations(ctx.searchConfig),
|
||||
cacheTtlMs: resolveCacheTtlMs(
|
||||
(ctx.searchConfig?.cacheTtlMinutes as number | undefined) ?? undefined,
|
||||
DEFAULT_CACHE_TTL_MINUTES,
|
||||
),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}),
|
||||
execute: async (args: Record<string, unknown>) => {
|
||||
const apiKey = resolveXaiWebSearchCredential(searchConfig);
|
||||
|
||||
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 plugins.entries.xai.config.webSearch.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const query = readStringParam(args, "query", { required: true });
|
||||
void readNumberParam(args, "count", { integer: true });
|
||||
|
||||
return await runXaiWebSearch({
|
||||
query,
|
||||
model: resolveXaiWebSearchModel(searchConfig),
|
||||
apiKey,
|
||||
timeoutSeconds: resolveTimeoutSeconds(
|
||||
(searchConfig?.timeoutSeconds as number | undefined) ?? undefined,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
),
|
||||
inlineCitations: resolveXaiInlineCitations(searchConfig),
|
||||
cacheTtlMs: resolveCacheTtlMs(
|
||||
(searchConfig?.cacheTtlMinutes as number | undefined) ?? undefined,
|
||||
DEFAULT_CACHE_TTL_MINUTES,
|
||||
),
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
buildXaiWebSearchPayload,
|
||||
extractXaiWebSearchContent,
|
||||
resolveXaiToolSearchConfig,
|
||||
resolveXaiInlineCitations,
|
||||
resolveXaiWebSearchCredential,
|
||||
resolveXaiWebSearchModel,
|
||||
requestXaiWebSearch,
|
||||
};
|
||||
|
||||
@@ -9,8 +9,8 @@ vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
resolveProviderModernModelRef: providerRuntimeMocks.resolveProviderModernModelRef,
|
||||
}));
|
||||
|
||||
import { normalizeModelCompat } from "../plugins/provider-model-compat.js";
|
||||
import { isHighSignalLiveModelRef, isModernModelRef } from "./live-model-filter.js";
|
||||
import { normalizeModelCompat } from "./model-compat.js";
|
||||
|
||||
const baseModel = (): Model<Api> =>
|
||||
({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createXaiFastModeWrapper } from "../../extensions/xai/stream.js";
|
||||
import { createConfiguredOllamaCompatNumCtxWrapper } from "../plugin-sdk/ollama.js";
|
||||
import { __testing as extraParamsTesting } from "./pi-embedded-runner/extra-params.js";
|
||||
import {
|
||||
@@ -58,6 +59,12 @@ beforeEach(() => {
|
||||
if (params.provider === "ollama") {
|
||||
return createConfiguredOllamaCompatNumCtxWrapper(params.context);
|
||||
}
|
||||
if (params.provider === "xai") {
|
||||
return createXaiFastModeWrapper(
|
||||
params.context.streamFn,
|
||||
params.context.extraParams?.fastMode === true,
|
||||
);
|
||||
}
|
||||
if (params.provider !== "openrouter") {
|
||||
return params.context.streamFn;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
resolveOpenAIServiceTier,
|
||||
} from "./openai-stream-wrappers.js";
|
||||
import { streamWithPayloadPatch } from "./stream-payload-utils.js";
|
||||
import { createXaiFastModeWrapper } from "./xai-stream-wrappers.js";
|
||||
|
||||
const defaultProviderRuntimeDeps = {
|
||||
prepareProviderExtraParams: prepareProviderExtraParamsRuntime,
|
||||
@@ -381,13 +380,6 @@ function applyPostPluginStreamWrappers(
|
||||
ctx.agent.streamFn,
|
||||
ctx.effectiveExtraParams.fastMode,
|
||||
);
|
||||
log.debug(
|
||||
`applying xAI fast mode=${ctx.effectiveExtraParams.fastMode} for ${ctx.provider}/${ctx.modelId}`,
|
||||
);
|
||||
ctx.agent.streamFn = createXaiFastModeWrapper(
|
||||
ctx.agent.streamFn,
|
||||
ctx.effectiveExtraParams.fastMode,
|
||||
);
|
||||
}
|
||||
|
||||
const openAIFastMode = resolveOpenAIFastMode(ctx.effectiveExtraParams);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import { normalizeModelCompat } from "../model-compat.js";
|
||||
import { normalizeModelCompat } from "../../plugins/provider-model-compat.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
|
||||
function isOpenAIApiBaseUrl(baseUrl?: string): boolean {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { ModelDefinitionConfig } from "../../config/types.js";
|
||||
import { resolveGoogleGenerativeAiTransport } from "../../plugin-sdk/google.js";
|
||||
import { normalizeModelCompat } from "../../plugins/provider-model-compat.js";
|
||||
import {
|
||||
buildProviderUnknownModelHintWithPlugin,
|
||||
clearProviderRuntimeHookCache,
|
||||
@@ -14,7 +15,6 @@ import { resolveOpenClawAgentDir } from "../agent-paths.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
import { buildModelAliasLines } from "../model-alias-lines.js";
|
||||
import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js";
|
||||
import { normalizeModelCompat } from "../model-compat.js";
|
||||
import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js";
|
||||
import {
|
||||
buildSuppressedBuiltInModelError,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
resolveTelegramReactionLevel,
|
||||
} from "../../../plugin-sdk/telegram-runtime.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
import { resolveToolCallArgumentsEncoding } from "../../../plugins/provider-model-compat.js";
|
||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||
import { buildTtsSystemPromptHint } from "../../../tts/tts.js";
|
||||
import { resolveUserPath } from "../../../utils.js";
|
||||
@@ -54,7 +55,6 @@ import { isTimeoutError } from "../../failover-error.js";
|
||||
import { resolveImageSanitizationLimits } from "../../image-sanitization.js";
|
||||
import { buildModelAliasLines } from "../../model-alias-lines.js";
|
||||
import { resolveModelAuthMode } from "../../model-auth.js";
|
||||
import { resolveToolCallArgumentsEncoding } from "../../model-compat.js";
|
||||
import { resolveDefaultModelForAgent } from "../../model-selection.js";
|
||||
import { supportsModelTools } from "../../model-tool-support.js";
|
||||
import { createOpenAIWebSocketStreamFn, releaseWsSession } from "../../openai-ws-stream.js";
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Context, Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createXaiFastModeWrapper } from "./xai-stream-wrappers.js";
|
||||
|
||||
function captureWrappedModelId(params: { modelId: string; fastMode: boolean }): string {
|
||||
let capturedModelId = "";
|
||||
const baseStreamFn: StreamFn = (model) => {
|
||||
capturedModelId = model.id;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrapped = createXaiFastModeWrapper(baseStreamFn, params.fastMode);
|
||||
void wrapped(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "xai",
|
||||
id: params.modelId,
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
return capturedModelId;
|
||||
}
|
||||
|
||||
describe("xai fast mode wrapper", () => {
|
||||
it("rewrites Grok 3 models to fast variants", () => {
|
||||
expect(captureWrappedModelId({ modelId: "grok-3", fastMode: true })).toBe("grok-3-fast");
|
||||
expect(captureWrappedModelId({ modelId: "grok-3-mini", fastMode: true })).toBe(
|
||||
"grok-3-mini-fast",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves unsupported or disabled models unchanged", () => {
|
||||
expect(captureWrappedModelId({ modelId: "grok-3-fast", fastMode: true })).toBe("grok-3-fast");
|
||||
expect(captureWrappedModelId({ modelId: "grok-3", fastMode: false })).toBe("grok-3");
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
|
||||
const XAI_FAST_MODEL_IDS = new Map<string, string>([
|
||||
["grok-3", "grok-3-fast"],
|
||||
["grok-3-mini", "grok-3-mini-fast"],
|
||||
["grok-4", "grok-4-fast"],
|
||||
["grok-4-0709", "grok-4-fast"],
|
||||
]);
|
||||
|
||||
function resolveXaiFastModelId(modelId: unknown): string | undefined {
|
||||
if (typeof modelId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return XAI_FAST_MODEL_IDS.get(modelId.trim());
|
||||
}
|
||||
|
||||
export function createXaiFastModeWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
fastMode: boolean,
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
if (!fastMode || model.api !== "openai-completions" || model.provider !== "xai") {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
const fastModelId = resolveXaiFastModelId(model.id);
|
||||
if (!fastModelId) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
return underlying({ ...model, id: fastModelId }, context, options);
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
import { hasNativeWebSearchTool } from "../plugins/provider-model-compat.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||
import { resolveGatewayMessageChannel } from "../utils/message-channel.js";
|
||||
@@ -18,7 +19,6 @@ 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";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stripXaiUnsupportedKeywords } from "./clean-for-xai.js";
|
||||
import { stripXaiUnsupportedKeywords } from "../../plugin-sdk/provider-tools.js";
|
||||
|
||||
describe("stripXaiUnsupportedKeywords", () => {
|
||||
it("strips minLength and maxLength from string properties", () => {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
stripXaiUnsupportedKeywords,
|
||||
XAI_UNSUPPORTED_SCHEMA_KEYWORDS,
|
||||
} from "../../plugin-sdk/provider-tools.js";
|
||||
|
||||
export { stripXaiUnsupportedKeywords, XAI_UNSUPPORTED_SCHEMA_KEYWORDS };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
|
||||
import { normalizeSecretInputString, resolveSecretInputRef } from "../../config/types.secrets.js";
|
||||
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
|
||||
|
||||
export function resolveWebSearchProviderCredential(params: {
|
||||
@@ -6,15 +6,22 @@ export function resolveWebSearchProviderCredential(params: {
|
||||
path: string;
|
||||
envVars: string[];
|
||||
}): string | undefined {
|
||||
const fromConfigRaw = normalizeResolvedSecretInputString({
|
||||
value: params.credentialValue,
|
||||
path: params.path,
|
||||
});
|
||||
const fromConfigRaw = normalizeSecretInputString(params.credentialValue);
|
||||
const fromConfig = normalizeSecretInput(fromConfigRaw);
|
||||
if (fromConfig) {
|
||||
return fromConfig;
|
||||
}
|
||||
|
||||
const credentialRef = resolveSecretInputRef({
|
||||
value: params.credentialValue,
|
||||
}).ref;
|
||||
if (credentialRef?.source === "env") {
|
||||
const fromEnvRef = normalizeSecretInput(process.env[credentialRef.id]);
|
||||
if (fromEnvRef) {
|
||||
return fromEnvRef;
|
||||
}
|
||||
}
|
||||
|
||||
for (const envVar of params.envVars) {
|
||||
const fromEnv = normalizeSecretInput(process.env[envVar]);
|
||||
if (fromEnv) {
|
||||
|
||||
121
src/plugins/provider-model-compat.ts
Normal file
121
src/plugins/provider-model-compat.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import type { ModelCompatConfig } from "../config/types.models.js";
|
||||
|
||||
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 hasToolSchemaProfile(
|
||||
modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined,
|
||||
profile: string,
|
||||
): boolean {
|
||||
return extractModelCompat(modelOrCompat)?.toolSchemaProfile === 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";
|
||||
}
|
||||
|
||||
function isOpenAINativeEndpoint(baseUrl: string): boolean {
|
||||
try {
|
||||
const host = new URL(baseUrl).hostname.toLowerCase();
|
||||
return host === "api.openai.com";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAnthropicMessagesModel(model: Model<Api>): model is Model<"anthropic-messages"> {
|
||||
return model.api === "anthropic-messages";
|
||||
}
|
||||
|
||||
function normalizeAnthropicBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/v1\/?$/, "");
|
||||
}
|
||||
|
||||
export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
||||
const baseUrl = model.baseUrl ?? "";
|
||||
|
||||
if (isAnthropicMessagesModel(model) && baseUrl) {
|
||||
const normalized = normalizeAnthropicBaseUrl(baseUrl);
|
||||
if (normalized !== baseUrl) {
|
||||
return { ...model, baseUrl: normalized } as Model<"anthropic-messages">;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpenAiCompletionsModel(model)) {
|
||||
return model;
|
||||
}
|
||||
|
||||
const compat = model.compat ?? undefined;
|
||||
const needsForce = baseUrl ? !isOpenAINativeEndpoint(baseUrl) : false;
|
||||
if (!needsForce) {
|
||||
return model;
|
||||
}
|
||||
const forcedDeveloperRole = compat?.supportsDeveloperRole === true;
|
||||
const hasStreamingUsageOverride = compat?.supportsUsageInStreaming !== undefined;
|
||||
const targetStrictMode = compat?.supportsStrictMode ?? false;
|
||||
if (
|
||||
compat?.supportsDeveloperRole !== undefined &&
|
||||
hasStreamingUsageOverride &&
|
||||
compat?.supportsStrictMode !== undefined
|
||||
) {
|
||||
return model;
|
||||
}
|
||||
|
||||
return {
|
||||
...model,
|
||||
compat: compat
|
||||
? {
|
||||
...compat,
|
||||
supportsDeveloperRole: forcedDeveloperRole || false,
|
||||
...(hasStreamingUsageOverride ? {} : { supportsUsageInStreaming: false }),
|
||||
supportsStrictMode: targetStrictMode,
|
||||
}
|
||||
: {
|
||||
supportsDeveloperRole: false,
|
||||
supportsUsageInStreaming: false,
|
||||
supportsStrictMode: false,
|
||||
},
|
||||
} as typeof model;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeModelCompat } from "../agents/model-compat.js";
|
||||
import { normalizeModelCompat } from "./provider-model-compat.js";
|
||||
import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel } from "./types.js";
|
||||
|
||||
export function matchesExactOrPrefix(id: string, values: readonly string[]): boolean {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "../secrets/runtime.js";
|
||||
import { runWebSearch } from "./runtime.js";
|
||||
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
|
||||
|
||||
type TestPluginWebSearchConfig = {
|
||||
webSearch?: {
|
||||
@@ -11,37 +8,92 @@ type TestPluginWebSearchConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
const { resolveBundledPluginWebSearchProvidersMock, resolveRuntimeWebSearchProvidersMock } =
|
||||
vi.hoisted(() => ({
|
||||
resolveBundledPluginWebSearchProvidersMock: vi.fn<() => PluginWebSearchProviderEntry[]>(
|
||||
() => [],
|
||||
),
|
||||
resolveRuntimeWebSearchProvidersMock: vi.fn<() => PluginWebSearchProviderEntry[]>(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/web-search-providers.js", () => ({
|
||||
resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
|
||||
resolvePluginWebSearchProviders: resolveRuntimeWebSearchProvidersMock,
|
||||
resolveRuntimeWebSearchProviders: resolveRuntimeWebSearchProvidersMock,
|
||||
}));
|
||||
|
||||
function createProvider(params: {
|
||||
pluginId: string;
|
||||
id: string;
|
||||
credentialPath: string;
|
||||
autoDetectOrder?: number;
|
||||
requiresCredential?: boolean;
|
||||
getCredentialValue?: PluginWebSearchProviderEntry["getCredentialValue"];
|
||||
getConfiguredCredentialValue?: PluginWebSearchProviderEntry["getConfiguredCredentialValue"];
|
||||
createTool?: PluginWebSearchProviderEntry["createTool"];
|
||||
}): PluginWebSearchProviderEntry {
|
||||
return {
|
||||
pluginId: params.pluginId,
|
||||
id: params.id,
|
||||
label: params.id,
|
||||
hint: `${params.id} runtime provider`,
|
||||
envVars: [`${params.id.toUpperCase()}_API_KEY`],
|
||||
placeholder: `${params.id}-...`,
|
||||
signupUrl: `https://example.com/${params.id}`,
|
||||
credentialPath: params.credentialPath,
|
||||
autoDetectOrder: params.autoDetectOrder,
|
||||
requiresCredential: params.requiresCredential,
|
||||
getCredentialValue: params.getCredentialValue ?? (() => undefined),
|
||||
setCredentialValue: () => {},
|
||||
getConfiguredCredentialValue: params.getConfiguredCredentialValue,
|
||||
createTool:
|
||||
params.createTool ??
|
||||
(() => ({
|
||||
description: params.id,
|
||||
parameters: {},
|
||||
execute: async (args) => ({ ...args, provider: params.id }),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("web search runtime", () => {
|
||||
let runWebSearch: typeof import("./runtime.js").runWebSearch;
|
||||
let activateSecretsRuntimeSnapshot: typeof import("../secrets/runtime.js").activateSecretsRuntimeSnapshot;
|
||||
let clearSecretsRuntimeSnapshot: typeof import("../secrets/runtime.js").clearSecretsRuntimeSnapshot;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resolveBundledPluginWebSearchProvidersMock.mockReset();
|
||||
resolveRuntimeWebSearchProvidersMock.mockReset();
|
||||
resolveBundledPluginWebSearchProvidersMock.mockReturnValue([]);
|
||||
resolveRuntimeWebSearchProvidersMock.mockReturnValue([]);
|
||||
({ runWebSearch } = await import("./runtime.js"));
|
||||
({ activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } =
|
||||
await import("../secrets/runtime.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
clearSecretsRuntimeSnapshot();
|
||||
});
|
||||
|
||||
it("executes searches through the active plugin registry", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.webSearchProviders.push({
|
||||
pluginId: "custom-search",
|
||||
pluginName: "Custom Search",
|
||||
provider: {
|
||||
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
|
||||
createProvider({
|
||||
pluginId: "custom-search",
|
||||
id: "custom",
|
||||
label: "Custom Search",
|
||||
hint: "Custom runtime provider",
|
||||
envVars: ["CUSTOM_SEARCH_API_KEY"],
|
||||
placeholder: "custom-...",
|
||||
signupUrl: "https://example.com/signup",
|
||||
credentialPath: "tools.web.search.custom.apiKey",
|
||||
autoDetectOrder: 1,
|
||||
getCredentialValue: () => "configured",
|
||||
setCredentialValue: () => {},
|
||||
createTool: () => ({
|
||||
description: "custom",
|
||||
parameters: {},
|
||||
execute: async (args) => ({ ...args, ok: true }),
|
||||
}),
|
||||
},
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
runWebSearch({
|
||||
@@ -55,48 +107,25 @@ describe("web search runtime", () => {
|
||||
});
|
||||
|
||||
it("auto-detects a provider from canonical plugin-owned credentials", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.webSearchProviders.push({
|
||||
const provider = createProvider({
|
||||
pluginId: "custom-search",
|
||||
pluginName: "Custom Search",
|
||||
provider: {
|
||||
id: "custom",
|
||||
label: "Custom Search",
|
||||
hint: "Custom runtime provider",
|
||||
envVars: ["CUSTOM_SEARCH_API_KEY"],
|
||||
placeholder: "custom-...",
|
||||
signupUrl: "https://example.com/signup",
|
||||
credentialPath: "plugins.entries.custom-search.config.webSearch.apiKey",
|
||||
autoDetectOrder: 1,
|
||||
getCredentialValue: () => undefined,
|
||||
setCredentialValue: () => {},
|
||||
getConfiguredCredentialValue: (config) => {
|
||||
const pluginConfig = config?.plugins?.entries?.["custom-search"]?.config as
|
||||
| TestPluginWebSearchConfig
|
||||
| undefined;
|
||||
return pluginConfig?.webSearch?.apiKey;
|
||||
},
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
configTarget.plugins = {
|
||||
...configTarget.plugins,
|
||||
entries: {
|
||||
...configTarget.plugins?.entries,
|
||||
"custom-search": {
|
||||
enabled: true,
|
||||
config: { webSearch: { apiKey: value } },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
createTool: () => ({
|
||||
description: "custom",
|
||||
parameters: {},
|
||||
execute: async (args) => ({ ...args, ok: true }),
|
||||
}),
|
||||
id: "custom",
|
||||
credentialPath: "plugins.entries.custom-search.config.webSearch.apiKey",
|
||||
autoDetectOrder: 1,
|
||||
getConfiguredCredentialValue: (config) => {
|
||||
const pluginConfig = config?.plugins?.entries?.["custom-search"]?.config as
|
||||
| TestPluginWebSearchConfig
|
||||
| undefined;
|
||||
return pluginConfig?.webSearch?.apiKey;
|
||||
},
|
||||
source: "test",
|
||||
createTool: () => ({
|
||||
description: "custom",
|
||||
parameters: {},
|
||||
execute: async (args) => ({ ...args, ok: true }),
|
||||
}),
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
resolveRuntimeWebSearchProvidersMock.mockReturnValue([provider]);
|
||||
resolveBundledPluginWebSearchProvidersMock.mockReturnValue([provider]);
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
@@ -124,32 +153,68 @@ describe("web search runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("treats non-env SecretRefs as configured credentials for provider auto-detect", async () => {
|
||||
const provider = createProvider({
|
||||
pluginId: "custom-search",
|
||||
id: "custom",
|
||||
credentialPath: "plugins.entries.custom-search.config.webSearch.apiKey",
|
||||
autoDetectOrder: 1,
|
||||
getConfiguredCredentialValue: (config) => {
|
||||
const pluginConfig = config?.plugins?.entries?.["custom-search"]?.config as
|
||||
| TestPluginWebSearchConfig
|
||||
| undefined;
|
||||
return pluginConfig?.webSearch?.apiKey;
|
||||
},
|
||||
createTool: () => ({
|
||||
description: "custom",
|
||||
parameters: {},
|
||||
execute: async (args) => ({ ...args, ok: true }),
|
||||
}),
|
||||
});
|
||||
resolveRuntimeWebSearchProvidersMock.mockReturnValue([provider]);
|
||||
resolveBundledPluginWebSearchProvidersMock.mockReturnValue([provider]);
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"custom-search": {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "file",
|
||||
provider: "vault",
|
||||
id: "/providers/custom-search/apiKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
runWebSearch({
|
||||
config,
|
||||
args: { query: "hello" },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: "custom",
|
||||
result: { query: "hello", ok: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a keyless provider when no credentials are available", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.webSearchProviders.push({
|
||||
pluginId: "duckduckgo",
|
||||
pluginName: "DuckDuckGo",
|
||||
provider: {
|
||||
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
|
||||
createProvider({
|
||||
pluginId: "duckduckgo",
|
||||
id: "duckduckgo",
|
||||
label: "DuckDuckGo Search (experimental)",
|
||||
hint: "Keyless fallback",
|
||||
requiresCredential: false,
|
||||
envVars: [],
|
||||
placeholder: "(no key needed)",
|
||||
signupUrl: "https://duckduckgo.com/",
|
||||
credentialPath: "",
|
||||
autoDetectOrder: 100,
|
||||
requiresCredential: false,
|
||||
getCredentialValue: () => "duckduckgo-no-key-needed",
|
||||
setCredentialValue: () => {},
|
||||
createTool: () => ({
|
||||
description: "duckduckgo",
|
||||
parameters: {},
|
||||
execute: async (args) => ({ ...args, provider: "duckduckgo" }),
|
||||
}),
|
||||
},
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
runWebSearch({
|
||||
@@ -163,21 +228,13 @@ describe("web search runtime", () => {
|
||||
});
|
||||
|
||||
it("prefers the active runtime-selected provider when callers omit runtime metadata", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.webSearchProviders.push({
|
||||
pluginId: "alpha-search",
|
||||
pluginName: "Alpha Search",
|
||||
provider: {
|
||||
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
|
||||
createProvider({
|
||||
pluginId: "alpha-search",
|
||||
id: "alpha",
|
||||
label: "Alpha Search",
|
||||
hint: "Alpha runtime provider",
|
||||
envVars: ["ALPHA_SEARCH_API_KEY"],
|
||||
placeholder: "alpha-...",
|
||||
signupUrl: "https://example.com/alpha",
|
||||
credentialPath: "tools.web.search.alpha.apiKey",
|
||||
autoDetectOrder: 1,
|
||||
getCredentialValue: () => "alpha-configured",
|
||||
setCredentialValue: () => {},
|
||||
createTool: ({ runtimeMetadata }) => ({
|
||||
description: "alpha",
|
||||
parameters: {},
|
||||
@@ -187,23 +244,13 @@ describe("web search runtime", () => {
|
||||
runtimeSelectedProvider: runtimeMetadata?.selectedProvider,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
source: "test",
|
||||
});
|
||||
registry.webSearchProviders.push({
|
||||
pluginId: "beta-search",
|
||||
pluginName: "Beta Search",
|
||||
provider: {
|
||||
}),
|
||||
createProvider({
|
||||
pluginId: "beta-search",
|
||||
id: "beta",
|
||||
label: "Beta Search",
|
||||
hint: "Beta runtime provider",
|
||||
envVars: ["BETA_SEARCH_API_KEY"],
|
||||
placeholder: "beta-...",
|
||||
signupUrl: "https://example.com/beta",
|
||||
credentialPath: "tools.web.search.beta.apiKey",
|
||||
autoDetectOrder: 2,
|
||||
getCredentialValue: () => "beta-configured",
|
||||
setCredentialValue: () => {},
|
||||
createTool: ({ runtimeMetadata }) => ({
|
||||
description: "beta",
|
||||
parameters: {},
|
||||
@@ -213,10 +260,9 @@ describe("web search runtime", () => {
|
||||
runtimeSelectedProvider: runtimeMetadata?.selectedProvider,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
}),
|
||||
]);
|
||||
|
||||
activateSecretsRuntimeSnapshot({
|
||||
sourceConfig: {},
|
||||
config: {},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { normalizeResolvedSecretInputString } from "../config/types.secrets.js";
|
||||
import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import type {
|
||||
PluginWebSearchProviderEntry,
|
||||
@@ -86,12 +86,18 @@ function hasEntryCredential(
|
||||
const rawValue =
|
||||
provider.getConfiguredCredentialValue?.(config) ??
|
||||
provider.getCredentialValue(search as Record<string, unknown> | undefined);
|
||||
const fromConfig = normalizeSecretInput(
|
||||
normalizeResolvedSecretInputString({
|
||||
value: rawValue,
|
||||
path: provider.credentialPath,
|
||||
}),
|
||||
);
|
||||
const configuredRef = resolveSecretInputRef({
|
||||
value: rawValue,
|
||||
}).ref;
|
||||
if (configuredRef && configuredRef.source !== "env") {
|
||||
return true;
|
||||
}
|
||||
const fromConfig = normalizeSecretInput(normalizeSecretInputString(rawValue));
|
||||
if (configuredRef?.source === "env") {
|
||||
return Boolean(
|
||||
normalizeSecretInput(process.env[configuredRef.id]) || readProviderEnvValue(provider.envVars),
|
||||
);
|
||||
}
|
||||
return Boolean(fromConfig || readProviderEnvValue(provider.envVars));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProviderPlugin, ProviderRuntimeModel } from "../../../src/plugins/types.js";
|
||||
import {
|
||||
@@ -676,6 +677,68 @@ export function describeXAIProviderRuntimeContract() {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("owns xai tool_stream defaults", () => {
|
||||
const provider = requireProviderContractProvider("xai");
|
||||
|
||||
expect(
|
||||
provider.prepareExtraParams?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast-reasoning",
|
||||
extraParams: { temperature: 0.2 },
|
||||
}),
|
||||
).toEqual({
|
||||
temperature: 0.2,
|
||||
tool_stream: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
provider.prepareExtraParams?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast-reasoning",
|
||||
extraParams: { tool_stream: false },
|
||||
}),
|
||||
).toEqual({
|
||||
tool_stream: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns xai fast-mode model rewriting through the plugin stream hook", () => {
|
||||
const provider = requireProviderContractProvider("xai");
|
||||
let capturedModelId = "";
|
||||
const baseStreamFn: StreamFn = (model) => {
|
||||
capturedModelId = model.id;
|
||||
return {
|
||||
push() {},
|
||||
async result() {
|
||||
return undefined;
|
||||
},
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// Minimal async stream surface for xAI decode wrappers.
|
||||
},
|
||||
} as unknown as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const streamFn = provider.wrapStreamFn?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4",
|
||||
extraParams: { fastMode: true },
|
||||
streamFn: baseStreamFn,
|
||||
});
|
||||
|
||||
expect(streamFn).toBeTypeOf("function");
|
||||
void streamFn?.(
|
||||
createModel({
|
||||
id: "grok-4",
|
||||
provider: "xai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
}) as never,
|
||||
{ messages: [] } as never,
|
||||
{},
|
||||
);
|
||||
expect(capturedModelId).toBe("grok-4-fast");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user