diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 51e9dfd8b98..05983b88baf 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -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), diff --git a/extensions/xai/src/grok-web-search-provider.test.ts b/extensions/xai/src/grok-web-search-provider.test.ts deleted file mode 100644 index fbc6f888f9c..00000000000 --- a/extensions/xai/src/grok-web-search-provider.test.ts +++ /dev/null @@ -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"], - }); - }); -}); diff --git a/extensions/xai/src/grok-web-search-provider.ts b/extensions/xai/src/grok-web-search-provider.ts deleted file mode 100644 index 94e4604c1d3..00000000000 --- a/extensions/xai/src/grok-web-search-provider.ts +++ /dev/null @@ -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 | 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; - 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) => - resolveXaiWebSearchModel(grok ? { grok } : undefined), - resolveGrokInlineCitations: (grok?: Record) => - resolveXaiInlineCitations(grok ? { grok } : undefined), - extractGrokContent: extractXaiWebSearchContent, - extractXaiWebSearchContent, - resolveXaiInlineCitations, - resolveXaiSearchConfig, - resolveXaiWebSearchModel, - requestXaiWebSearch, - buildXaiWebSearchPayload, -} as const; diff --git a/extensions/xai/stream.test.ts b/extensions/xai/stream.test.ts new file mode 100644 index 00000000000..07ee61ed88a --- /dev/null +++ b/extensions/xai/stream.test.ts @@ -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; + }; + + 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; + }; + 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"); + }); +}); diff --git a/extensions/xai/stream.ts b/extensions/xai/stream.ts index 390d9ac201d..e52fd26bb05 100644 --- a/extensions/xai/stream.ts +++ b/extensions/xai/stream.ts @@ -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([ + ["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(""", '"') diff --git a/extensions/xai/test-api.ts b/extensions/xai/test-api.ts deleted file mode 100644 index 0e1cf9add05..00000000000 --- a/extensions/xai/test-api.ts +++ /dev/null @@ -1 +0,0 @@ -export { __testing } from "./src/grok-web-search-provider.js"; diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 6b015ebd118..ea12c76f8d6 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -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", diff --git a/extensions/xai/web-search.ts b/extensions/xai/web-search.ts index 1bbdfeb190b..cbb502f8b4a 100644 --- a/extensions/xai/web-search.ts +++ b/extensions/xai/web-search.ts @@ -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; + searchConfig?: Record; +}): 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, value: unknown) => setScopedCredentialValue(searchConfigTarget, "grok", value), - createTool: (ctx: { searchConfig?: Record }) => ({ - description: - "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.", - parameters: Type.Object({ - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: 10, - }), - ), - }), - execute: async (args: Record) => { - const apiKey = resolveWebSearchProviderCredential({ - credentialValue: getScopedCredentialValue(ctx.searchConfig, "grok"), - path: "tools.web.search.grok.apiKey", - envVars: ["XAI_API_KEY"], - }); - - if (!apiKey) { - return { - error: "missing_xai_api_key", - message: - "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure 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) => { + 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, }; diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 90045f9af65..14093ec474b 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -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 => ({ diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 120687eab9c..3c23b934f30 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -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; } diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 0595954231b..7f474e5fc7c 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -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); diff --git a/src/agents/pi-embedded-runner/model.provider-normalization.ts b/src/agents/pi-embedded-runner/model.provider-normalization.ts index 3b6f67d3946..5d1b3b88ca6 100644 --- a/src/agents/pi-embedded-runner/model.provider-normalization.ts +++ b/src/agents/pi-embedded-runner/model.provider-normalization.ts @@ -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 { diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 44f454c321b..c5d426f6922 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index e396a049118..86a965e730d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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"; diff --git a/src/agents/pi-embedded-runner/xai-stream-wrappers.test.ts b/src/agents/pi-embedded-runner/xai-stream-wrappers.test.ts deleted file mode 100644 index a005f2c4721..00000000000 --- a/src/agents/pi-embedded-runner/xai-stream-wrappers.test.ts +++ /dev/null @@ -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; - }; - - 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"); - }); -}); diff --git a/src/agents/pi-embedded-runner/xai-stream-wrappers.ts b/src/agents/pi-embedded-runner/xai-stream-wrappers.ts deleted file mode 100644 index ceef1dcb36d..00000000000 --- a/src/agents/pi-embedded-runner/xai-stream-wrappers.ts +++ /dev/null @@ -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([ - ["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); - }; -} diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 101fac8645c..d8444581f6a 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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"; diff --git a/src/agents/schema/clean-for-xai.test.ts b/src/agents/schema/clean-for-xai.test.ts index b51b829257d..9f1188f54f8 100644 --- a/src/agents/schema/clean-for-xai.test.ts +++ b/src/agents/schema/clean-for-xai.test.ts @@ -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", () => { diff --git a/src/agents/schema/clean-for-xai.ts b/src/agents/schema/clean-for-xai.ts deleted file mode 100644 index aff13a19a1d..00000000000 --- a/src/agents/schema/clean-for-xai.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { - stripXaiUnsupportedKeywords, - XAI_UNSUPPORTED_SCHEMA_KEYWORDS, -} from "../../plugin-sdk/provider-tools.js"; - -export { stripXaiUnsupportedKeywords, XAI_UNSUPPORTED_SCHEMA_KEYWORDS }; diff --git a/src/agents/tools/web-search-provider-credentials.ts b/src/agents/tools/web-search-provider-credentials.ts index 69d98792171..dfe7ca63557 100644 --- a/src/agents/tools/web-search-provider-credentials.ts +++ b/src/agents/tools/web-search-provider-credentials.ts @@ -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) { diff --git a/src/plugins/provider-model-compat.ts b/src/plugins/provider-model-compat.ts new file mode 100644 index 00000000000..aa114ba2b58 --- /dev/null +++ b/src/plugins/provider-model-compat.ts @@ -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( + 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): 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): model is Model<"anthropic-messages"> { + return model.api === "anthropic-messages"; +} + +function normalizeAnthropicBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/v1\/?$/, ""); +} + +export function normalizeModelCompat(model: Model): Model { + 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; +} diff --git a/src/plugins/provider-model-helpers.ts b/src/plugins/provider-model-helpers.ts index 08e2651b6cb..c8a6c0159f9 100644 --- a/src/plugins/provider-model-helpers.ts +++ b/src/plugins/provider-model-helpers.ts @@ -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 { diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index 48aa24104a2..3dd42c5ed8f 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -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: {}, diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index 31040fb3df7..898b12d49b1 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -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 | 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)); } diff --git a/test/helpers/extensions/provider-runtime-contract.ts b/test/helpers/extensions/provider-runtime-contract.ts index 77c60ceed32..77c77ec02f9 100644 --- a/test/helpers/extensions/provider-runtime-contract.ts +++ b/test/helpers/extensions/provider-runtime-contract.ts @@ -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; + }; + + 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"); + }); }); }