diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e354d65d3..9ea8bd30caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars. - Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97. - Web search: late-bind managed agent `web_search` calls to the current runtime config snapshot, so existing sessions do not keep stale unresolved SecretRefs after secrets reload. Fixes #75420. Thanks @richardmqq. +- Web search: honor `baseUrl` overrides for Gemini, Grok, and x_search provider-owned config, so proxy-backed search tools no longer dial hardcoded public endpoints. Supersedes #61972. Thanks @Lanfei. - Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570. - macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc. - Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index be1b0245a74..0c63fb9b77f 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -1d9157a39ad18841d666af90c58e0539d6427cbd2ad0c1ce29047a5a2131ba7e config-baseline.json +6666a7f876a31658b3e2f2a6564619cfaf2b282104fd6d7799656389431eb996 config-baseline.json 80e6e8dce647aef2d1310de55a81d27de52cca47fc24bd7ad81b80f43a72b84c config-baseline.core.json 1cec599c3d27c258b9df3446baa547cb164e502afa9b30c052bba8737183f551 config-baseline.channel.json -8346667910d2b3a3884efce8f96591adebc4f7ea99ce18337b80e4d70bf8e4d2 config-baseline.plugin.json +1b2cb7fec6752245bc2a3da4a835f0bf9d31e6a468e777a5bdb91820398f44d0 config-baseline.plugin.json diff --git a/docs/providers/google.md b/docs/providers/google.md index 97bf78177d8..fc5aea174b3 100644 --- a/docs/providers/google.md +++ b/docs/providers/google.md @@ -144,6 +144,33 @@ Choose your preferred auth method and follow the setup steps. | Thinking/reasoning | Yes (Gemini 2.5+ / Gemini 3+) | | Gemma 4 models | Yes | +## Web search + +The bundled `gemini` web-search provider uses Gemini Google Search grounding. +Configure it under `plugins.entries.google.config.webSearch`: + +```json5 +{ + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: "AIza...", // optional if GEMINI_API_KEY is set + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + model: "gemini-2.5-flash", + }, + }, + }, + }, + }, +} +``` + +`webSearch.baseUrl` is optional and exists for operator proxies or compatible +Gemini API endpoints. See [Gemini search](/tools/gemini-search) for the +provider-specific tool behavior. + Gemini 3 models use `thinkingLevel` rather than `thinkingBudget`. OpenClaw maps Gemini 3, Gemini 3.1, and `gemini-*-latest` alias reasoning controls to diff --git a/docs/providers/xai.md b/docs/providers/xai.md index 57ad309d20b..c468afe27b6 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -37,6 +37,8 @@ OpenClaw uses the xAI Responses API as the bundled xAI transport. The same and remote `code_execution`. If you store an xAI key under `plugins.entries.xai.config.webSearch.apiKey`, the bundled xAI model provider reuses that key as a fallback too. +Set `plugins.entries.xai.config.webSearch.baseUrl` to route Grok `web_search` +and, by default, `x_search` through an operator xAI Responses proxy. `code_execution` tuning lives under `plugins.entries.xai.config.codeExecution`. @@ -343,6 +345,7 @@ Legacy aliases still normalize to the canonical bundled ids: | ------------------ | ------- | ------------------ | ------------------------------------ | | `enabled` | boolean | — | Enable or disable x_search | | `model` | string | `grok-4-1-fast` | Model used for x_search requests | + | `baseUrl` | string | — | xAI Responses base URL override | | `inlineCitations` | boolean | — | Include inline citations in results | | `maxTurns` | number | — | Maximum conversation turns | | `timeoutSeconds` | number | — | Request timeout in seconds | @@ -357,6 +360,7 @@ Legacy aliases still normalize to the canonical bundled ids: xSearch: { enabled: true, model: "grok-4-1-fast", + baseUrl: "https://api.x.ai/v1", inlineCitations: true, }, }, @@ -429,6 +433,9 @@ Legacy aliases still normalize to the canonical bundled ids: - `web_search`, `x_search`, and `code_execution` are exposed as OpenClaw tools. OpenClaw enables the specific xAI built-in it needs inside each tool request instead of attaching all native tools to every chat turn. + - Grok `web_search` reads `plugins.entries.xai.config.webSearch.baseUrl`. + `x_search` reads `plugins.entries.xai.config.xSearch.baseUrl`, then + falls back to the Grok web-search base URL. - `x_search` and `code_execution` are owned by the bundled xAI plugin rather than hardcoded into the core model runtime. - `code_execution` is remote xAI sandbox execution, not local diff --git a/docs/tools/gemini-search.md b/docs/tools/gemini-search.md index 8de3af1b06c..b90ad17851a 100644 --- a/docs/tools/gemini-search.md +++ b/docs/tools/gemini-search.md @@ -39,6 +39,7 @@ citations. config: { webSearch: { apiKey: "AIza...", // optional if GEMINI_API_KEY is set + baseUrl: "https://generativelanguage.googleapis.com/v1beta", // optional proxy/base URL override model: "gemini-2.5-flash", // default }, }, @@ -89,6 +90,14 @@ The default model is `gemini-2.5-flash` (fast and cost-effective). Any Gemini model that supports grounding can be used via `plugins.entries.google.config.webSearch.model`. +## Base URL overrides + +Set `plugins.entries.google.config.webSearch.baseUrl` when Gemini web search +must route through an operator proxy or custom Gemini-compatible endpoint. A +plain `https://generativelanguage.googleapis.com` value is normalized to +`https://generativelanguage.googleapis.com/v1beta`; custom proxy paths are kept +as provided after trimming trailing slashes. + ## Related - [Web Search overview](/tools/web) -- all providers and auto-detection diff --git a/docs/tools/grok-search.md b/docs/tools/grok-search.md index 190e384835b..8a995f26b9c 100644 --- a/docs/tools/grok-search.md +++ b/docs/tools/grok-search.md @@ -61,6 +61,7 @@ If you skip it, you can enable or change `x_search` later in config. config: { webSearch: { apiKey: "xai-...", // optional if XAI_API_KEY is set + baseUrl: "https://api.x.ai/v1", // optional Responses API proxy/base URL override }, }, }, @@ -97,6 +98,14 @@ Grok uses a provider-specific 60 second default timeout because xAI Responses web-grounded searches can run longer than the shared `web_search` default. Set `tools.web.search.timeoutSeconds` to override it. +## Base URL overrides + +Set `plugins.entries.xai.config.webSearch.baseUrl` when Grok web search should +route through an operator proxy or xAI-compatible Responses endpoint. OpenClaw +posts to `/responses` after trimming trailing slashes. `x_search` +uses the same `webSearch.baseUrl` fallback unless +`plugins.entries.xai.config.xSearch.baseUrl` is set. + ## Related - [Web Search overview](/tools/web) -- all providers and auto-detection diff --git a/docs/tools/web.md b/docs/tools/web.md index c4d3ee1e7a0..32453114e46 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -334,6 +334,7 @@ tool on the request that serves this tool call. xSearch: { enabled: true, model: "grok-4-1-fast-non-reasoning", + baseUrl: "https://api.x.ai/v1", // optional, overrides webSearch.baseUrl inlineCitations: false, maxTurns: 2, timeoutSeconds: 30, @@ -341,6 +342,7 @@ tool on the request that serves this tool call. }, webSearch: { apiKey: "xai-...", // optional if XAI_API_KEY is set + baseUrl: "https://api.x.ai/v1", // optional shared xAI Responses base URL }, }, }, @@ -349,6 +351,11 @@ tool on the request that serves this tool call. } ``` +`x_search` posts to `/responses` when +`plugins.entries.xai.config.xSearch.baseUrl` is set. If that field is omitted, +it falls back to `plugins.entries.xai.config.webSearch.baseUrl`, then the +legacy `tools.web.search.grok.baseUrl`, and finally the public xAI endpoint. + ### x_search parameters | Parameter | Description | diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index c96916f19b8..f9225234b34 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -133,6 +133,10 @@ "webSearch.model": { "label": "Gemini Search Model", "help": "Gemini model override for web search grounding." + }, + "webSearch.baseUrl": { + "label": "Gemini Search Base URL", + "help": "Optional Gemini API base URL for web search grounding proxies." } }, "contracts": { @@ -177,6 +181,9 @@ }, "model": { "type": "string" + }, + "baseUrl": { + "type": "string" } } } diff --git a/extensions/google/src/gemini-web-search-provider.runtime.ts b/extensions/google/src/gemini-web-search-provider.runtime.ts index defcc137f70..21203f2a185 100644 --- a/extensions/google/src/gemini-web-search-provider.runtime.ts +++ b/extensions/google/src/gemini-web-search-provider.runtime.ts @@ -20,15 +20,13 @@ import { wrapWebContent, writeCachedSearchPayload, } from "openclaw/plugin-sdk/provider-web-search"; -import { DEFAULT_GOOGLE_API_BASE_URL } from "../api.js"; import { resolveGeminiConfig, + resolveGeminiBaseUrl, resolveGeminiModel, type GeminiConfig, } from "./gemini-web-search-provider.shared.js"; -const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL; - type GeminiGroundingResponse = { candidates?: Array<{ content?: { @@ -62,10 +60,11 @@ export function resolveGeminiRuntimeApiKey(gemini?: GeminiConfig): string | unde async function runGeminiSearch(params: { query: string; apiKey: string; + baseUrl: string; model: string; timeoutSeconds: number; }): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { - const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`; + const endpoint = `${params.baseUrl}/models/${params.model}:generateContent`; return withTrustedWebSearchEndpoint( { @@ -161,10 +160,12 @@ export async function executeGeminiSearch( const count = readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; const model = resolveGeminiModel(geminiConfig); + const baseUrl = resolveGeminiBaseUrl(geminiConfig); const cacheKey = buildSearchCacheKey([ "gemini", query, resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + baseUrl, model, ]); const cached = readCachedSearchPayload(cacheKey); @@ -176,6 +177,7 @@ export async function executeGeminiSearch( const result = await runGeminiSearch({ query, apiKey, + baseUrl, model, timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), }); diff --git a/extensions/google/src/gemini-web-search-provider.shared.ts b/extensions/google/src/gemini-web-search-provider.shared.ts index 8588ea4724d..eeed0414c52 100644 --- a/extensions/google/src/gemini-web-search-provider.shared.ts +++ b/extensions/google/src/gemini-web-search-provider.shared.ts @@ -1,7 +1,10 @@ +import { normalizeGoogleApiBaseUrl } from "../api.js"; + const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash"; export type GeminiConfig = { apiKey?: unknown; + baseUrl?: unknown; model?: unknown; }; @@ -28,3 +31,7 @@ export function resolveGeminiApiKey( export function resolveGeminiModel(gemini?: GeminiConfig): string { return trimToUndefined(gemini?.model) ?? DEFAULT_GEMINI_WEB_SEARCH_MODEL; } + +export function resolveGeminiBaseUrl(gemini?: GeminiConfig): string { + return normalizeGoogleApiBaseUrl(trimToUndefined(gemini?.baseUrl)); +} diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index 98be12866a5..8d6609e0cae 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -5,7 +5,11 @@ import { type WebSearchProviderPlugin, type WebSearchProviderToolDefinition, } from "openclaw/plugin-sdk/provider-web-search-config-contract"; -import { resolveGeminiApiKey, resolveGeminiModel } from "./gemini-web-search-provider.shared.js"; +import { + resolveGeminiApiKey, + resolveGeminiBaseUrl, + resolveGeminiModel, +} from "./gemini-web-search-provider.shared.js"; const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey"; @@ -82,5 +86,6 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin { export const __testing = { resolveGeminiApiKey, + resolveGeminiBaseUrl, resolveGeminiModel, } as const; diff --git a/extensions/google/web-search-provider.test.ts b/extensions/google/web-search-provider.test.ts index 93d546b65ab..2b4fca86e79 100644 --- a/extensions/google/web-search-provider.test.ts +++ b/extensions/google/web-search-provider.test.ts @@ -1,8 +1,33 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { withEnv, withEnvAsync } from "openclaw/plugin-sdk/test-env"; -import { describe, expect, it } from "vitest"; +import { withEnv, withEnvAsync, withFetchPreconnect } from "openclaw/plugin-sdk/test-env"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { __testing, createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js"; +function installGeminiFetch() { + const mockFetch = vi.fn((_input?: unknown, _init?: unknown) => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + candidates: [ + { + content: { parts: [{ text: "Grounded answer" }] }, + groundingMetadata: { + groundingChunks: [{ web: { uri: "https://example.com", title: "Example" } }], + }, + }, + ], + }), + } as Response), + ); + global.fetch = withFetchPreconnect(mockFetch); + return mockFetch; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("google web search provider", () => { it("points missing-key users to fetch/browser alternatives", async () => { await withEnvAsync({ GEMINI_API_KEY: undefined }, async () => { @@ -47,4 +72,38 @@ describe("google web search provider", () => { expect(__testing.resolveGeminiModel()).toBe("gemini-2.5-flash"); expect(__testing.resolveGeminiModel({ model: " gemini-2.5-pro " })).toBe("gemini-2.5-pro"); }); + + it("routes Gemini web search through plugin webSearch.baseUrl", async () => { + const mockFetch = installGeminiFetch(); + const provider = createGeminiWebSearchProvider(); + const tool = provider.createTool({ + config: { + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: "AIza-plugin-test", + baseUrl: "https://generativelanguage.googleapis.com/proxy/v1beta/", + }, + }, + }, + }, + }, + }, + searchConfig: { provider: "gemini" }, + }); + + await tool?.execute({ query: "OpenClaw docs" }); + + expect(String(mockFetch.mock.calls[0]?.[0])).toBe( + "https://generativelanguage.googleapis.com/proxy/v1beta/models/gemini-2.5-flash:generateContent", + ); + }); + + it("normalizes Gemini shorthand base URLs", () => { + expect( + __testing.resolveGeminiBaseUrl({ baseUrl: "https://generativelanguage.googleapis.com" }), + ).toBe("https://generativelanguage.googleapis.com/v1beta"); + }); }); diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index 501eba39b79..13beecd7b43 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -62,6 +62,10 @@ "label": "Grok Search Model", "help": "Grok model override for web search." }, + "webSearch.baseUrl": { + "label": "Grok Search Base URL", + "help": "Optional xAI Responses API base URL for Grok web_search and x_search fallbacks." + }, "webSearch.inlineCitations": { "label": "Inline Citations", "help": "Include inline markdown citations in Grok responses." @@ -78,6 +82,10 @@ "label": "X Search Model", "help": "xAI model override for x_search." }, + "xSearch.baseUrl": { + "label": "X Search Base URL", + "help": "Optional xAI Responses API base URL for x_search requests." + }, "xSearch.inlineCitations": { "label": "X Search Inline Citations", "help": "Keep inline markdown citations from xAI in x_search responses when available." @@ -144,6 +152,9 @@ "model": { "type": "string" }, + "baseUrl": { + "type": "string" + }, "inlineCitations": { "type": "boolean" } @@ -159,6 +170,9 @@ "model": { "type": "string" }, + "baseUrl": { + "type": "string" + }, "inlineCitations": { "type": "boolean" }, diff --git a/extensions/xai/src/responses-tool-shared.ts b/extensions/xai/src/responses-tool-shared.ts index 3d1e1129a05..87bfc2c1f0b 100644 --- a/extensions/xai/src/responses-tool-shared.ts +++ b/extensions/xai/src/responses-tool-shared.ts @@ -18,7 +18,16 @@ function extractUrlCitations(annotations: unknown): string[] { .map((annotation) => annotation.url as string); } -export const XAI_RESPONSES_ENDPOINT = "https://api.x.ai/v1/responses"; +export const XAI_RESPONSES_BASE_URL = "https://api.x.ai/v1"; +export const XAI_RESPONSES_ENDPOINT = `${XAI_RESPONSES_BASE_URL}/responses`; + +function trimString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function resolveXaiResponsesEndpoint(baseUrl?: unknown): string { + return `${(trimString(baseUrl) ?? XAI_RESPONSES_BASE_URL).replace(/\/+$/, "")}/responses`; +} export function buildXaiResponsesToolBody(params: { model: string; @@ -105,5 +114,7 @@ export const __testing = { extractXaiWebSearchContent, resolveXaiResponseTextCitationsAndInline, resolveXaiResponseTextAndCitations, + resolveXaiResponsesEndpoint, + XAI_RESPONSES_BASE_URL, XAI_RESPONSES_ENDPOINT, } as const; diff --git a/extensions/xai/src/web-search-provider.runtime.ts b/extensions/xai/src/web-search-provider.runtime.ts index 76601db9a3f..1754b739fbc 100644 --- a/extensions/xai/src/web-search-provider.runtime.ts +++ b/extensions/xai/src/web-search-provider.runtime.ts @@ -19,6 +19,7 @@ import { extractXaiWebSearchContent, requestXaiWebSearch, resolveXaiInlineCitations, + resolveXaiWebSearchEndpoint, resolveXaiWebSearchModel, } from "./web-search-shared.js"; import { resolveEffectiveXSearchConfig, setPluginXSearchConfigValue } from "./x-search-config.js"; @@ -120,13 +121,14 @@ export async function runXaiSearchProviderSetup( function runXaiWebSearch(params: { query: string; model: string; + endpoint: string; apiKey: string; timeoutSeconds: number; inlineCitations: boolean; cacheTtlMs: number; }): Promise> { const cacheKey = normalizeCacheKey( - `grok:${params.model}:${String(params.inlineCitations)}:${params.query}`, + `grok:${params.endpoint}:${params.model}:${String(params.inlineCitations)}:${params.query}`, ); const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey); if (cached) { @@ -139,6 +141,7 @@ function runXaiWebSearch(params: { query: params.query, model: params.model, apiKey: params.apiKey, + endpoint: params.endpoint, timeoutSeconds: params.timeoutSeconds, inlineCitations: params.inlineCitations, }); @@ -205,6 +208,7 @@ export async function executeXaiWebSearchProviderTool( return await runXaiWebSearch({ query, model: resolveXaiWebSearchModel(searchConfig), + endpoint: resolveXaiWebSearchEndpoint(searchConfig), apiKey, timeoutSeconds: resolveXaiWebSearchTimeoutSeconds(searchConfig), inlineCitations: resolveXaiInlineCitations(searchConfig), @@ -218,6 +222,7 @@ export const __testing = { resolveXaiToolSearchConfig, resolveXaiInlineCitations, resolveXaiWebSearchCredential, + resolveXaiWebSearchEndpoint, resolveXaiWebSearchModel, resolveXaiWebSearchTimeoutSeconds, requestXaiWebSearch, diff --git a/extensions/xai/src/web-search-shared.ts b/extensions/xai/src/web-search-shared.ts index bee2c743042..4ab20e08881 100644 --- a/extensions/xai/src/web-search-shared.ts +++ b/extensions/xai/src/web-search-shared.ts @@ -4,17 +4,17 @@ import { buildXaiResponsesToolBody, extractXaiWebSearchContent, resolveXaiResponseTextCitationsAndInline, - XAI_RESPONSES_ENDPOINT, + resolveXaiResponsesEndpoint, } from "./responses-tool-shared.js"; import { isRecord } from "./tool-config-shared.js"; import type { XaiWebSearchResponse } from "./web-search-response.types.js"; export { extractXaiWebSearchContent } from "./responses-tool-shared.js"; export type { XaiWebSearchResponse } from "./web-search-response.types.js"; -const XAI_WEB_SEARCH_ENDPOINT = XAI_RESPONSES_ENDPOINT; const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast"; type XaiWebSearchConfig = Record & { + baseUrl?: unknown; model?: unknown; inlineCitations?: unknown; }; @@ -64,6 +64,10 @@ export function resolveXaiWebSearchModel(searchConfig?: Record) : XAI_DEFAULT_WEB_SEARCH_MODEL; } +export function resolveXaiWebSearchEndpoint(searchConfig?: Record): string { + return resolveXaiResponsesEndpoint(resolveXaiSearchConfig(searchConfig).baseUrl); +} + export function resolveXaiInlineCitations(searchConfig?: Record): boolean { return resolveXaiSearchConfig(searchConfig).inlineCitations === true; } @@ -89,12 +93,13 @@ export async function requestXaiWebSearch(params: { query: string; model: string; apiKey: string; + endpoint: string; timeoutSeconds: number; inlineCitations: boolean; }): Promise { return await postTrustedWebToolsJson( { - url: XAI_WEB_SEARCH_ENDPOINT, + url: params.endpoint, timeoutSeconds: params.timeoutSeconds, apiKey: params.apiKey, body: buildXaiResponsesToolBody({ diff --git a/extensions/xai/src/x-search-config.ts b/extensions/xai/src/x-search-config.ts index 8880d7b9b7d..f39ea19aeb3 100644 --- a/extensions/xai/src/x-search-config.ts +++ b/extensions/xai/src/x-search-config.ts @@ -24,19 +24,44 @@ function resolvePluginXSearchConfig(config?: OpenClawConfig): JsonRecord | undef return cloneRecord(pluginConfig.xSearch); } +function resolveLegacyGrokWebSearchConfig(config?: OpenClawConfig): JsonRecord | undefined { + const web = config?.tools?.web as Record | undefined; + const search = web?.search; + if (!isRecord(search) || !isRecord(search.grok)) { + return undefined; + } + return cloneRecord(search.grok); +} + +function resolvePluginWebSearchConfig(config?: OpenClawConfig): JsonRecord | undefined { + const pluginConfig = config?.plugins?.entries?.xai?.config; + if (!isRecord(pluginConfig?.webSearch)) { + return undefined; + } + return cloneRecord(pluginConfig.webSearch); +} + +function baseUrlFallback(config?: JsonRecord): JsonRecord | undefined { + return typeof config?.baseUrl === "string" && config.baseUrl.trim() + ? { baseUrl: config.baseUrl } + : undefined; +} + export function resolveEffectiveXSearchConfig(config?: OpenClawConfig): JsonRecord | undefined { + const legacyGrokBaseUrl = baseUrlFallback(resolveLegacyGrokWebSearchConfig(config)); + const pluginWebSearchBaseUrl = baseUrlFallback(resolvePluginWebSearchConfig(config)); const legacy = resolveLegacyXSearchConfig(config); const pluginOwned = resolvePluginXSearchConfig(config); - if (!legacy) { - return pluginOwned; - } - if (!pluginOwned) { - return legacy; - } - return { - ...legacy, - ...pluginOwned, + const merged = { + ...(legacyGrokBaseUrl ?? {}), + ...(pluginWebSearchBaseUrl ?? {}), + ...(legacy ?? {}), + ...(pluginOwned ?? {}), }; + if (Object.keys(merged).length === 0) { + return undefined; + } + return merged; } export function setPluginXSearchConfigValue( diff --git a/extensions/xai/src/x-search-shared.ts b/extensions/xai/src/x-search-shared.ts index f3ac9c798f4..ce3d88beaf4 100644 --- a/extensions/xai/src/x-search-shared.ts +++ b/extensions/xai/src/x-search-shared.ts @@ -2,7 +2,7 @@ import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/pro import { buildXaiResponsesToolBody, resolveXaiResponseTextCitationsAndInline, - XAI_RESPONSES_ENDPOINT, + resolveXaiResponsesEndpoint, } from "./responses-tool-shared.js"; import { coerceXaiToolConfig, @@ -11,11 +11,11 @@ import { } from "./tool-config-shared.js"; import { type XaiWebSearchResponse } from "./web-search-shared.js"; -const XAI_X_SEARCH_ENDPOINT = XAI_RESPONSES_ENDPOINT; export const XAI_DEFAULT_X_SEARCH_MODEL = "grok-4-1-fast-non-reasoning"; type XaiXSearchConfig = { apiKey?: unknown; + baseUrl?: unknown; model?: unknown; inlineCitations?: unknown; maxTurns?: unknown; @@ -48,6 +48,10 @@ export function resolveXaiXSearchModel(config?: Record): string }); } +export function resolveXaiXSearchEndpoint(config?: Record): string { + return resolveXaiResponsesEndpoint(resolveXaiXSearchConfig(config).baseUrl); +} + export function resolveXaiXSearchInlineCitations(config?: Record): boolean { return resolveXaiXSearchConfig(config).inlineCitations === true; } @@ -106,6 +110,7 @@ export function buildXaiXSearchPayload(params: { export async function requestXaiXSearch(params: { apiKey: string; + endpoint: string; model: string; timeoutSeconds: number; inlineCitations: boolean; @@ -114,7 +119,7 @@ export async function requestXaiXSearch(params: { }): Promise { return await postTrustedWebToolsJson( { - url: XAI_X_SEARCH_ENDPOINT, + url: params.endpoint, timeoutSeconds: params.timeoutSeconds, apiKey: params.apiKey, body: buildXaiResponsesToolBody({ diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 290b6a9ab7c..326cb0f04b3 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -1,8 +1,8 @@ import { createTestWizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime"; import { NON_ENV_SECRETREF_MARKER } from "openclaw/plugin-sdk/provider-auth-runtime"; import { createNonExitingRuntime } from "openclaw/plugin-sdk/runtime-env"; -import { withEnv, withEnvAsync } from "openclaw/plugin-sdk/test-env"; -import { describe, expect, it, vi } from "vitest"; +import { withEnv, withEnvAsync, withFetchPreconnect } from "openclaw/plugin-sdk/test-env"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveXaiCatalogEntry } from "./model-definitions.js"; import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js"; @@ -19,6 +19,29 @@ const { resolveXaiWebSearchTimeoutSeconds, } = __testing; +function installXaiWebSearchFetch() { + const mockFetch = vi.fn((_input?: unknown, _init?: unknown) => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + output: [ + { + type: "message", + content: [{ type: "output_text", text: "Grounded Grok answer" }], + }, + ], + }), + } as Response), + ); + global.fetch = withFetchPreconnect(mockFetch); + return mockFetch; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("xai web search config resolution", () => { it("prefers configured api keys and resolves grok scoped defaults", () => { expect(resolveXaiWebSearchCredential({ grok: { apiKey: "xai-secret" } })).toBe("xai-secret"); @@ -268,6 +291,32 @@ describe("xai web search config resolution", () => { ); }); + it("routes Grok web search through plugin webSearch.baseUrl", async () => { + const mockFetch = installXaiWebSearchFetch(); + const provider = createXaiWebSearchProvider(); + const tool = provider.createTool({ + config: { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: "xai-config-test", + baseUrl: "https://api.x.ai/proxy/v1/", + }, + }, + }, + }, + }, + }, + searchConfig: { provider: "grok" }, + }); + + await tool?.execute({ query: "OpenClaw Grok proxy test" }); + + expect(String(mockFetch.mock.calls[0]?.[0])).toBe("https://api.x.ai/proxy/v1/responses"); + }); + it("normalizes deprecated grok 4.20 beta model ids to GA ids", () => { expect( resolveXaiWebSearchModel({ diff --git a/extensions/xai/x-search.test.ts b/extensions/xai/x-search.test.ts index 4ee60b2503a..efbdcbc6fc1 100644 --- a/extensions/xai/x-search.test.ts +++ b/extensions/xai/x-search.test.ts @@ -136,6 +136,88 @@ describe("xai x_search tool", () => { ]); }); + it("routes x_search through plugin-owned xSearch.baseUrl", async () => { + const mockFetch = installXSearchFetch(); + const tool = createXSearchTool({ + config: { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: "xai-config-test", // pragma: allowlist secret + }, + xSearch: { + enabled: true, + baseUrl: "https://api.x.ai/xai-search/v1/", + }, + }, + }, + }, + }, + }, + }); + + await tool?.execute?.("x-search:plugin-base-url", { + query: "base url route", + }); + + expect(String(mockFetch.mock.calls[0]?.[0])).toBe("https://api.x.ai/xai-search/v1/responses"); + }); + + it("falls back to Grok web search baseUrl for x_search", async () => { + const mockFetch = installXSearchFetch(); + const tool = createXSearchTool({ + config: { + tools: { + web: { + search: { + grok: { + apiKey: "xai-legacy-key", // pragma: allowlist secret + baseUrl: "https://api.x.ai/legacy/v1/", + }, + }, + }, + }, + }, + }); + + await tool?.execute?.("x-search:legacy-grok-base-url", { + query: "legacy base url route", + }); + + expect(String(mockFetch.mock.calls[0]?.[0])).toBe("https://api.x.ai/legacy/v1/responses"); + }); + + it("shares plugin webSearch.baseUrl with x_search when xSearch.baseUrl is unset", async () => { + const mockFetch = installXSearchFetch(); + const tool = createXSearchTool({ + config: { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: "xai-plugin-key", // pragma: allowlist secret + baseUrl: "https://api.x.ai/shared/v1/", + }, + xSearch: { + enabled: true, + }, + }, + }, + }, + }, + }, + }); + + await tool?.execute?.("x-search:web-search-base-url", { + query: "shared base url route", + }); + + expect(String(mockFetch.mock.calls[0]?.[0])).toBe("https://api.x.ai/shared/v1/responses"); + }); + it("reuses the xAI plugin web search key for x_search requests", async () => { const mockFetch = installXSearchFetch(); const tool = createXSearchTool({ diff --git a/extensions/xai/x-search.ts b/extensions/xai/x-search.ts index 88c4ad10001..8605334cb00 100644 --- a/extensions/xai/x-search.ts +++ b/extensions/xai/x-search.ts @@ -13,6 +13,7 @@ import { resolveEffectiveXSearchConfig } from "./src/x-search-config.js"; import { buildXaiXSearchPayload, requestXaiXSearch, + resolveXaiXSearchEndpoint, resolveXaiXSearchInlineCitations, resolveXaiXSearchMaxTurns, resolveXaiXSearchModel, @@ -100,6 +101,7 @@ function normalizeOptionalIsoDate(value: string | undefined, label: string): str function buildXSearchCacheKey(params: { query: string; model: string; + endpoint: string; inlineCitations: boolean; maxTurns?: number; options: Omit; @@ -107,6 +109,7 @@ function buildXSearchCacheKey(params: { return JSON.stringify([ "x_search", params.model, + params.endpoint, params.query, params.inlineCitations, params.maxTurns ?? null, @@ -164,11 +167,13 @@ export function createXSearchTool(options?: { }; const xSearchConfigRecord = xSearchConfig; const model = resolveXaiXSearchModel(xSearchConfigRecord); + const endpoint = resolveXaiXSearchEndpoint(xSearchConfigRecord); const inlineCitations = resolveXaiXSearchInlineCitations(xSearchConfigRecord); const maxTurns = resolveXaiXSearchMaxTurns(xSearchConfigRecord); const cacheKey = buildXSearchCacheKey({ query, model, + endpoint, inlineCitations, maxTurns, options: { @@ -188,6 +193,7 @@ export function createXSearchTool(options?: { const startedAt = Date.now(); const result = await requestXaiXSearch({ apiKey, + endpoint, model, timeoutSeconds: resolveTimeoutSeconds(xSearchConfig?.timeoutSeconds, 30), inlineCitations,